Files
mono/blog/sx/sx_components.py
giles c1ad6fd8d4 Replace Python sx_call loops with data-driven SX defcomps using map
Move rendering logic from Python for-loops building sx_call strings into
SX defcomp components that use map/lambda over data dicts. Python now
serializes display data into plain dicts and passes them via a single
sx_call; the SX layer handles iteration and conditional rendering.

Covers orders (rows, items, calendar, tickets), federation (timeline,
search, actors, profile activities), and blog (cards, pages, filters,
snippets, menu items, tag groups, page search, nav OOB).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:03:29 +00:00

2337 lines
99 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,
mobile_menu_sx,
mobile_root_nav_sx,
post_mobile_nav_sx,
post_admin_mobile_nav_sx,
)
# Load blog service .sx component definitions + handler definitions
load_service_components(os.path.dirname(os.path.dirname(__file__)), service_name="blog")
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)
def _post_admin_mobile_menu(ctx: dict, selected: str = "") -> str:
"""Full mobile menu for any post admin page (admin + post + root)."""
slug = (ctx.get("post") or {}).get("slug", "")
return mobile_menu_sx(
post_admin_mobile_nav_sx(ctx, slug, selected),
post_mobile_nav_sx(ctx),
mobile_root_nav_sx(ctx),
)
# ---------------------------------------------------------------------------
# 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.defpage_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.defpage_menu_items_page", "bars", "Menu Items"),
("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"),
("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"),
("settings.defpage_cache_page", "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")
post_dicts = [_blog_card_data(p, ctx) for p in posts]
sentinel = SxExpr(_blog_sentinel_sx(ctx))
return sx_call("blog-cards-from-data", posts=post_dicts, view=view, sentinel=sentinel)
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_data(post: dict, ctx: dict) -> dict:
"""Serialize a blog post to a display-data dict for sx rendering."""
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 [])
d: dict[str, Any] = 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:
d["liked"] = post.get("is_liked", False)
d["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
d["csrf_token"] = _ctx_csrf(ctx)
if tags:
d["tags"] = tags
if authors:
d["authors"] = authors
if widget:
d["widget"] = widget
return d
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_card_data(page: dict, ctx: dict) -> dict:
"""Serialize a page to a display-data dict for sx rendering."""
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 {}
return dict(
href=href, hx_select=hx_select, title=page.get("title", ""),
has_calendar=features.get("calendar", False),
has_market=features.get("market", False),
pub_timestamp=_format_ts(page.get("published_at")),
feature_image=page.get("feature_image"),
excerpt=page.get("custom_excerpt") or page.get("excerpt", ""),
)
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)
page_dicts = [_page_card_data(pg, ctx) for pg in pages]
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}"
sentinel = SxExpr(sx_call("sentinel-simple",
id=f"sentinel-{page_num}-d", next_url=next_url))
elif pages:
sentinel = SxExpr(sx_call("end-of-results"))
elif not pages:
sentinel = SxExpr(sx_call("blog-no-pages"))
else:
sentinel = None
return sx_call("page-cards-from-data", pages=page_dicts, sentinel=sentinel)
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 = list(ctx.get("selected_groups") or ())
hx_select = ctx.get("hx_select_search", "#main-panel")
group_dicts = []
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
style = (f"background-color: {g_colour}; color: white;" if g_colour
else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
group_dicts.append(dict(
slug=g_slug, name=g_name, feature_image=g_fi,
style=style, initial=g_name[:1], count=str(g_count),
))
return sx_call("blog-tag-groups-filter-from-data",
groups=group_dicts, selected_groups=selected_groups, hx_select=hx_select)
def _authors_filter_sx(ctx: dict) -> str:
"""Author filter bar as sx."""
authors = ctx.get("authors") or []
selected_authors = list(ctx.get("selected_authors") or ())
hx_select = ctx.get("hx_select_search", "#main-panel")
author_dicts = []
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)
author_dicts.append(dict(
slug=a_slug, name=a_name, profile_image=a_img, count=str(a_count),
))
return sx_call("blog-authors-filter-from-data",
authors=author_dicts, selected_authors=selected_authors, hx_select=hx_select)
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.defpage_post_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", "")
sx_content = post.get("sx_content", "")
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,
sx_content=SxExpr(sx_content) if sx_content else None,
)
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 or sx_content."""
post = ctx.get("post") or {}
html = post.get("html", "")
sx_content = post.get("sx_content", "")
return sx_call("blog-home-main",
html_content=html,
sx_content=SxExpr(sx_content) if sx_content else None)
# ---------------------------------------------------------------------------
# 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",
}
snippet_dicts = []
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")
snippet_dicts.append(dict(
id=s_id, name=s_name, user_id=s_uid, visibility=s_vis,
patch_url=qurl("snippets.patch_visibility", snippet_id=s_id),
delete_url=qurl("snippets.delete_snippet", snippet_id=s_id),
))
return sx_call("blog-snippets-from-data",
snippets=snippet_dicts, user_id=user_id, is_admin=is_admin,
csrf=csrf, badge_colours=badge_colours)
# ---------------------------------------------------------------------------
# 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!")
item_dicts = []
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)
item_dicts.append(dict(
label=label, slug=slug, feature_image=fi,
sort_order=str(sort),
edit_url=qurl("menu_items.edit_menu_item", item_id=i_id),
delete_url=qurl("menu_items.delete_menu_item_route", item_id=i_id),
))
return sx_call("blog-menu-items-from-data", items=item_dicts, csrf=csrf)
# ---------------------------------------------------------------------------
# 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")
group_dicts = []
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)
style = (f"background-color: {g_colour}; color: white;" if g_colour
else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
group_dicts.append(dict(
name=g_name, slug=g_slug, feature_image=g_fi,
style=style, initial=g_name[:1], sort_order=str(g_sort),
edit_href=qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
))
unassigned_dicts = []
for tag in unassigned_tags:
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
unassigned_dicts.append(dict(name=t_name))
return sx_call("blog-tag-groups-from-data",
groups=group_dicts, unassigned_tags=unassigned_dicts,
csrf=csrf, create_url=create_url)
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 — pass data dicts to defcomp
tag_dicts = []
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")
tag_dicts.append(dict(
tag_id=str(t_id), name=t_name, feature_image=t_fi,
checked=t_id in assigned_tag_ids,
))
tags_sx = sx_call("blog-tag-checkboxes-from-data", tags=tag_dicts)
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(tags_sx),
)
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 = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
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)
return oob_page_sx(oobs=header_oob, content=content, aside=aside,
filter=filter_sx)
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")
sx_editor_js = asset_url_fn("scripts/sx-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 + sx editor styles
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
parts.append(sx_call("sx-editor-styles"))
# 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"
" // Font size overrides disabled — caused global font shrinking\n"
" // function applyEditorFontSize() {\n"
" // document.documentElement.style.fontSize = '62.5%';\n"
" // document.body.style.fontSize = '1.6rem';\n"
" // }\n"
" // applyEditorFontSize();\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"
" if (typeof SxEditor !== 'undefined') {\n"
" SxEditor.mount('sx-editor', {\n"
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
" onChange: function(sx) {\n"
" document.getElementById('sx-content-input').value = sx;\n"
" }\n"
" });\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,
sx_editor_js_src=sx_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)
# ---- 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 = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
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 = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
oobs = post_oob
return oob_page_sx(oobs=oobs, content=content, menu=menu)
# ---- Post admin ----
# ===========================================================================
def _post_data_content_sx(ctx: dict) -> str:
"""Build post data inspector panel natively (replaces _types/post_data/_main_panel.html)."""
from markupsafe import escape as esc
from quart import g
original_post = getattr(g, "post_data", {}).get("original_post")
if original_post is None:
return _raw_html_sx('<div class="px-4 py-8 text-stone-400">No post data available.</div>')
tablename = getattr(original_post, "__tablename__", "?")
def _render_scalar_table(obj):
rows = []
for col in obj.__mapper__.columns:
key = col.key
if key == "_sa_instance_state":
continue
val = getattr(obj, key, None)
if val is None:
val_html = '<span class="text-neutral-400">\u2014</span>'
elif hasattr(val, "isoformat"):
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(val.isoformat())}</code></pre>'
elif isinstance(val, str):
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs">{esc(val)}</pre>'
else:
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(str(val))}</code></pre>'
rows.append(
f'<tr class="border-t border-neutral-200 align-top">'
f'<td class="px-3 py-2 whitespace-nowrap text-neutral-600 align-top">{esc(key)}</td>'
f'<td class="px-3 py-2 align-top">{val_html}</td></tr>'
)
return (
'<div class="w-full overflow-x-auto sm:overflow-visible">'
'<table class="w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden">'
'<thead class="bg-neutral-50/70"><tr>'
'<th class="px-3 py-2 text-left font-medium w-40 sm:w-56">Field</th>'
'<th class="px-3 py-2 text-left font-medium">Value</th>'
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table></div>'
)
def _render_model(obj, depth=0, max_depth=2):
parts = [_render_scalar_table(obj)]
rel_parts = []
for rel in obj.__mapper__.relationships:
rel_name = rel.key
loaded = rel_name in obj.__dict__
value = getattr(obj, rel_name, None) if loaded else None
cardinality = "many" if rel.uselist else "one"
cls_name = rel.mapper.class_.__name__
loaded_label = "" if loaded else " \u2022 <em>not loaded</em>"
inner = ""
if value is None:
inner = '<span class="text-neutral-400">\u2014</span>'
elif rel.uselist:
items = list(value) if value else []
inner = f'<div class="text-neutral-500 mb-2">{len(items)} item{"" if len(items) == 1 else "s"}</div>'
if items and depth < max_depth:
sub_rows = []
for i, it in enumerate(items, 1):
ident_parts = []
for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
if k in it.__mapper__.c:
v = getattr(it, k, "")
ident_parts.append(f"{k}={v}")
summary = " \u2022 ".join(ident_parts) if ident_parts else str(it)
child_html = ""
if depth < max_depth:
child_html = f'<div class="mt-2 pl-3 border-l border-neutral-200">{_render_model(it, depth + 1, max_depth)}</div>'
else:
child_html = '<div class="mt-1 text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
sub_rows.append(
f'<tr class="border-t border-neutral-200 align-top">'
f'<td class="px-2 py-1 whitespace-nowrap align-top">{i}</td>'
f'<td class="px-2 py-1 align-top"><pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(summary)}</code></pre>{child_html}</td></tr>'
)
inner += (
'<div class="w-full overflow-x-auto sm:overflow-visible">'
'<table class="w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden">'
'<thead class="bg-neutral-50/70"><tr><th class="px-2 py-1 text-left w-10">#</th>'
'<th class="px-2 py-1 text-left">Summary</th></tr></thead><tbody>'
+ "".join(sub_rows) + '</tbody></table></div>'
)
else:
child = value
ident_parts = []
for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
if k in child.__mapper__.c:
v = getattr(child, k, "")
ident_parts.append(f"{k}={v}")
summary = " \u2022 ".join(ident_parts) if ident_parts else str(child)
inner = f'<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{esc(summary)}</code></pre>'
if depth < max_depth:
inner += f'<div class="pl-3 border-l border-neutral-200">{_render_model(child, depth + 1, max_depth)}</div>'
else:
inner += '<div class="text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
rel_parts.append(
f'<div class="rounded-xl border border-neutral-200">'
f'<div class="px-3 py-2 bg-neutral-50/70 text-sm font-medium">'
f'Relationship: <span class="font-semibold">{esc(rel_name)}</span>'
f' <span class="ml-2 text-xs text-neutral-500">{cardinality} \u2192 {esc(cls_name)}{loaded_label}</span></div>'
f'<div class="p-3 text-sm">{inner}</div></div>'
)
if rel_parts:
parts.append('<div class="space-y-3">' + "".join(rel_parts) + '</div>')
return '<div class="space-y-4">' + "".join(parts) + '</div>'
html = (
f'<div class="px-4 py-8">'
f'<div class="mb-6 text-sm text-neutral-500">Model: <code>Post</code> \u2022 Table: <code>{esc(tablename)}</code></div>'
f'{_render_model(original_post, 0, 2)}</div>'
)
return _raw_html_sx(html)
# ===========================================================================
def _preview_main_panel_sx(ctx: dict) -> str:
"""Build the preview panel with 4 expandable sections."""
sections: list[str] = []
# 1. Prettified SX source
sx_pretty = ctx.get("sx_pretty", "")
if sx_pretty:
sections.append(sx_call("blog-preview-section",
title="S-Expression Source",
content=SxExpr(sx_pretty),
))
# 2. Prettified Lexical JSON
json_pretty = ctx.get("json_pretty", "")
if json_pretty:
sections.append(sx_call("blog-preview-section",
title="Lexical JSON",
content=SxExpr(json_pretty),
))
# 3. SX rendered preview
sx_rendered = ctx.get("sx_rendered", "")
if sx_rendered:
rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(sx_rendered)}))'
sections.append(sx_call("blog-preview-section",
title="SX Rendered",
content=SxExpr(rendered_sx),
))
# 4. Lexical rendered preview
lex_rendered = ctx.get("lex_rendered", "")
if lex_rendered:
rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(lex_rendered)}))'
sections.append(sx_call("blog-preview-section",
title="Lexical Rendered",
content=SxExpr(rendered_sx),
))
if not sections:
return '(div :class "p-8 text-stone-500" "No content to preview.")'
inner = " ".join(sections)
return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))
# ===========================================================================
def _post_entries_content_sx(ctx: dict) -> str:
"""Build post entries panel natively (replaces _types/post_entries/_main_panel.html)."""
from quart import g, url_for as qurl
from shared.utils import host_url
all_calendars = ctx.get("all_calendars", [])
associated_entry_ids = ctx.get("associated_entry_ids", set())
post_slug = g.post_data["post"]["slug"]
# Associated entries list (reuse existing render function)
assoc_html = render_associated_entries(all_calendars, associated_entry_ids, post_slug)
# Calendar browser
cal_items: list[str] = []
for cal in all_calendars:
cal_post = getattr(cal, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = escape(getattr(cal_post, "title", "")) if cal_post else ""
cal_name = escape(getattr(cal, "name", ""))
cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id))
img_html = (
f'<img src="{escape(cal_fi)}" alt="{cal_title}" class="w-12 h-12 rounded object-cover flex-shrink-0" />'
if cal_fi else
'<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>'
)
cal_items.append(
f'<details class="border rounded-lg bg-white" data-toggle-group="calendar-browser">'
f'<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">'
f'{img_html}'
f'<div class="flex-1">'
f'<div class="font-semibold flex items-center gap-2"><i class="fa fa-calendar text-stone-500"></i> {cal_name}</div>'
f'<div class="text-sm text-stone-600">{cal_title}</div>'
f'</div></summary>'
f'<div class="p-4 border-t" sx-get="{escape(cal_view_url)}" sx-trigger="intersect once" sx-swap="innerHTML">'
f'<div class="text-sm text-stone-400">Loading calendar...</div>'
f'</div></details>'
)
if cal_items:
browser_html = (
'<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3>'
+ "".join(cal_items) + '</div>'
)
else:
browser_html = '<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3><div class="text-sm text-stone-400">No calendars found.</div></div>'
# assoc_html is sx (from render_associated_entries); browser is raw HTML
# Wrap the whole thing: open div as raw, then associated entries (sx), then browser (raw), close div
return (
_raw_html_sx('<div id="post-entries-content" class="space-y-6 p-4">')
+ assoc_html
+ _raw_html_sx(browser_html + '</div>')
)
# ---- Calendar view (for entries browser) ----
def render_calendar_view(
calendar, year, month, month_name, weekday_names, weeks,
prev_month, prev_month_year, next_month, next_month_year,
prev_year, next_year, month_entries, associated_entry_ids,
post_slug: str,
) -> str:
"""Build calendar month grid HTML (replaces _types/post/admin/_calendar_view.html)."""
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
from shared.utils import host_url
esc = escape
csrf = generate_csrf_token()
cal_id = calendar.id
def cal_url(y, m):
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
cur_url = cal_url(year, month)
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
# Navigation header
nav = (
f'<header class="flex items-center justify-center mb-4">'
f'<nav class="flex items-center gap-2 text-xl">'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&laquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&lsaquo;</a>'
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&rsaquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&raquo;</a>'
f'</nav></header>'
)
# Weekday header
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
# Grid cells
cells: list[str] = []
for week in weeks:
for day in week:
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
day_date = day.date
entry_btns: list[str] = []
for e in month_entries:
e_start = getattr(e, "start_at", None)
if not e_start or e_start.date() != day_date:
continue
e_id = getattr(e, "id", None)
e_name = esc(getattr(e, "name", ""))
t_url = toggle_url_fn(e_id)
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
if e_id in associated_entry_ids:
entry_btns.append(
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
f'<span class="truncate flex-1">{e_name}</span>'
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
f' data-confirm data-confirm-title="Remove entry?"'
f' data-confirm-text="Remove {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' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><i class="fa fa-times"></i></button></div>'
)
else:
entry_btns.append(
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
f' data-confirm data-confirm-title="Add entry?"'
f' data-confirm-text="Add {e_name} to this post?"'
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><span class="truncate block">{e_name}</span></button>'
)
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
cells.append(
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
)
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
html = (
f'<div id="calendar-view-{cal_id}"'
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
f'{nav}'
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
f'</div>'
)
return _raw_html_sx(html)
# ---- 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) + ")"
def _post_edit_content_sx(ctx: dict) -> str:
"""Build WYSIWYG editor panel as SX expression (edit page)."""
from quart import url_for as qurl, current_app, g, request as qrequest
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
ghost_post = ctx.get("ghost_post", {}) or {}
save_success = ctx.get("save_success", False)
save_error = ctx.get("save_error", "")
newsletters = ctx.get("newsletters", [])
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")
sx_editor_js = asset_url_fn("scripts/sx-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", "")
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
is_page = post.get("is_page", False)
feature_image = ghost_post.get("feature_image") or ""
feature_image_caption = ghost_post.get("feature_image_caption") or ""
title_val = ghost_post.get("title") or ""
excerpt_val = ghost_post.get("custom_excerpt") or ""
updated_at = ghost_post.get("updated_at") or ""
status = ghost_post.get("status") or "draft"
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
sx_content = ghost_post.get("sx_content") or ""
has_sx = bool(sx_content)
already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
email_obj = ghost_post.get("email")
if email_obj and not isinstance(email_obj, dict):
already_emailed = bool(getattr(email_obj, "status", None))
title_placeholder = "Page title..." if is_page else "Post title..."
# Newsletter options as SX fragment
nl_parts = ['(option :value "" "Select newsletter\u2026")']
for nl in newsletters:
nl_slug = sx_serialize(getattr(nl, "slug", ""))
nl_name = sx_serialize(getattr(nl, "name", ""))
nl_parts.append(f"(option :value {nl_slug} {nl_name})")
nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")")
# Footer extra badges as SX fragment
badge_parts: list[str] = []
if save_success:
badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")')
publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
if publish_requested:
badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")')
if post.get("publish_requested"):
badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")')
if already_emailed:
nl_name = ""
newsletter = ghost_post.get("newsletter")
if newsletter:
nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "")
suffix = f" to {nl_name}" if nl_name else ""
badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")')
footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None
parts: list[str] = []
# Error banner
if save_error:
parts.append(sx_call("blog-editor-error", error=save_error))
# Form (sx_content_val populates #sx-content-input; JS reads from there)
parts.append(sx_call("blog-editor-edit-form",
csrf=csrf,
updated_at=str(updated_at),
title_val=title_val,
excerpt_val=excerpt_val,
feature_image=feature_image,
feature_image_caption=feature_image_caption,
sx_content_val=sx_content,
lexical_json=lexical_json,
has_sx=has_sx,
title_placeholder=title_placeholder,
status=status,
already_emailed=already_emailed,
newsletter_options=nl_opts_sx,
footer_extra=footer_extra_sx,
))
# Publish-mode JS
parts.append(sx_call("blog-editor-publish-js", already_emailed=already_emailed))
# Editor CSS + styles
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
parts.append(sx_call("sx-editor-styles"))
# Editor JS + init
init_js = (
'(function() {'
" function applyEditorFontSize() {"
" document.documentElement.style.fontSize = '62.5%';"
" document.body.style.fontSize = '1.6rem';"
' }'
" function restoreDefaultFontSize() {"
" document.documentElement.style.fontSize = '';"
" document.body.style.fontSize = '';"
' }'
' applyEditorFontSize();'
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {"
" if (e.detail.target && e.detail.target.id === 'main-panel') {"
' restoreDefaultFontSize();'
" document.body.removeEventListener('htmx:beforeSwap', cleanup);"
' }'
' });'
' function init() {'
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;"
f" var uploadUrl = '{upload_image_url}';"
' var uploadUrls = {'
' image: uploadUrl,'
f" media: '{upload_media_url}',"
f" file: '{upload_file_url}',"
' };'
" var fileInput = document.getElementById('feature-image-file');"
" var addBtn = document.getElementById('feature-image-add-btn');"
" var deleteBtn = document.getElementById('feature-image-delete-btn');"
" var preview = document.getElementById('feature-image-preview');"
" var emptyState = document.getElementById('feature-image-empty');"
" var filledState = document.getElementById('feature-image-filled');"
" var hiddenUrl = document.getElementById('feature-image-input');"
" var hiddenCaption = document.getElementById('feature-image-caption-input');"
" var captionInput = document.getElementById('feature-image-caption');"
" var uploading = document.getElementById('feature-image-uploading');"
' function showFilled(url) {'
' preview.src = url; hiddenUrl.value = url;'
" emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');"
' }'
' function showEmpty() {'
" preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';"
" emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');"
' }'
' function uploadFile(file) {'
" emptyState.classList.add('hidden'); uploading.classList.remove('hidden');"
" var fd = new FormData(); fd.append('file', file);"
" fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })"
" .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })"
' .then(function(data) {'
' var url = data.images && data.images[0] && data.images[0].url;'
" if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }"
' })'
' .catch(function(e) { showEmpty(); alert(e.message); });'
' }'
" addBtn.addEventListener('click', function() { fileInput.click(); });"
" preview.addEventListener('click', function() { fileInput.click(); });"
" deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });"
" fileInput.addEventListener('change', function() {"
' if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }'
' });'
" captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');"
" function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }"
" excerpt.addEventListener('input', autoResize); autoResize();"
' var dataEl = document.getElementById(\'lexical-initial-data\');'
' var initialJson = dataEl ? dataEl.textContent.trim() : null;'
' if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }'
" window.mountEditor('lexical-editor', {"
' initialJson: initialJson,'
' csrfToken: csrfToken,'
' uploadUrls: uploadUrls,'
f" oembedUrl: '{oembed_url}',"
f" unsplashApiKey: '{unsplash_key}',"
f" snippetsUrl: '{snippets_url}',"
' });'
" if (typeof SxEditor !== 'undefined') {"
" SxEditor.mount('sx-editor', {"
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
' csrfToken: csrfToken,'
' uploadUrls: uploadUrls,'
f" oembedUrl: '{oembed_url}',"
' onChange: function(sx) {'
" document.getElementById('sx-content-input').value = sx;"
' }'
' });'
' }'
" document.addEventListener('keydown', function(e) {"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
" e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();"
' }'
' });'
' }'
" if (typeof window.mountEditor === 'function') { init(); }"
' else { var _t = setInterval(function() {'
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
' }, 50); }'
'})();'
)
parts.append(sx_call("blog-editor-scripts",
js_src=editor_js,
sx_editor_js_src=sx_editor_js,
init_js=init_js))
return "(<> " + " ".join(parts) + ")"
# ===========================================================================
def _post_settings_content_sx(ctx: dict) -> str:
"""Build settings form natively (replaces _types/post_settings/_main_panel.html)."""
from quart import g
from shared.browser.app.csrf import generate_csrf_token
esc = escape
ghost_post = ctx.get("ghost_post", {}) or {}
save_success = ctx.get("save_success", False)
csrf = generate_csrf_token()
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
is_page = post.get("is_page", False)
def field_label(text, field_for=None):
for_attr = f' for="{field_for}"' if field_for else ''
return f'<label{for_attr} class="block text-[13px] font-medium text-stone-500 mb-[4px]">{esc(text)}</label>'
input_cls = ('w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] '
'bg-white text-stone-700 placeholder:text-stone-300 '
'focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300')
textarea_cls = input_cls + ' resize-y'
def text_input(name, value='', placeholder='', input_type='text', maxlength=None):
ml = f' maxlength="{maxlength}"' if maxlength else ''
return (f'<input type="{input_type}" name="{name}" id="settings-{name}" value="{esc(value)}"'
f' placeholder="{esc(placeholder)}"{ml} class="{input_cls}">')
def textarea_input(name, value='', placeholder='', rows=3, maxlength=None):
ml = f' maxlength="{maxlength}"' if maxlength else ''
return (f'<textarea name="{name}" id="settings-{name}" rows="{rows}"'
f' placeholder="{esc(placeholder)}"{ml} class="{textarea_cls}">{esc(value)}</textarea>')
def checkbox_input(name, checked=False, label=''):
chk = ' checked' if checked else ''
return (f'<label class="inline-flex items-center gap-[8px] cursor-pointer">'
f'<input type="checkbox" name="{name}" id="settings-{name}"{chk}'
f' class="rounded border-stone-300 text-stone-600 focus:ring-stone-300">'
f'<span class="text-[14px] text-stone-600">{esc(label)}</span></label>')
def section(title, content, is_open=False):
open_attr = ' open' if is_open else ''
return (f'<details class="border border-stone-200 rounded-[8px] overflow-hidden"{open_attr}>'
f'<summary class="px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors">{esc(title)}</summary>'
f'<div class="px-[16px] py-[12px] space-y-[12px]">{content}</div></details>')
gp = ghost_post
# General section
slug_placeholder = 'page-slug' if is_page else 'post-slug'
pub_at = gp.get("published_at") or ""
pub_at_val = pub_at[:16] if pub_at else ""
vis = gp.get("visibility") or "public"
vis_opts = "".join(
f'<option value="{v}"{" selected" if vis == v else ""}>{l}</option>'
for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")]
)
general = (
f'<div>{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}</div>'
f'<div>{field_label("Published at", "settings-published_at")}'
f'<input type="datetime-local" name="published_at" id="settings-published_at" value="{esc(pub_at_val)}" class="{input_cls}"></div>'
f'<div>{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}</div>'
f'<div>{field_label("Visibility", "settings-visibility")}'
f'<select name="visibility" id="settings-visibility" class="{input_cls}">{vis_opts}</select></div>'
f'<div>{checkbox_input("email_only", gp.get("email_only"), "Email only")}</div>'
)
# Tags
tags = gp.get("tags") or []
if tags:
tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags)
else:
tag_names = ""
tags_sec = (
f'<div>{field_label("Tags (comma-separated)", "settings-tags")}'
f'{text_input("tags", tag_names, "news, updates, featured")}'
f'<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p></div>'
)
# Feature image
fi_sec = f'<div>{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}</div>'
# SEO
seo_sec = (
f'<div>{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}'
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p></div>'
f'<div>{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}'
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p></div>'
f'<div>{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}</div>'
)
# Facebook / OG
og_sec = (
f'<div>{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}</div>'
f'<div>{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}</div>'
f'<div>{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}</div>'
)
# Twitter
tw_sec = (
f'<div>{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}</div>'
f'<div>{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}</div>'
f'<div>{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}</div>'
)
# Advanced
tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs'
adv_sec = f'<div>{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}</div>'
sections = (
section("General", general, is_open=True)
+ section("Tags", tags_sec)
+ section("Feature Image", fi_sec)
+ section("SEO / Meta", seo_sec)
+ section("Facebook / OpenGraph", og_sec)
+ section("X / Twitter", tw_sec)
+ section("Advanced", adv_sec)
)
saved_html = '<span class="text-[14px] text-green-600">Saved.</span>' if save_success else ''
html = (
f'<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="updated_at" value="{esc(gp.get("updated_at") or "")}">'
f'<div class="space-y-[12px] mt-[16px]">{sections}</div>'
f'<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">'
f'<button type="submit" class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer">Save settings</button>'
f'{saved_html}</div></form>'
)
return _raw_html_sx(html)
# ===========================================================================
# ===========================================================================
# ===========================================================================
# ===========================================================================
# ===========================================================================
# ===========================================================================
# ===========================================================================
# 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_item_form(menu_item=None) -> str:
"""Render menu item add/edit form (replaces _types/menu_items/_form.html)."""
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
search_url = qurl("menu_items.search_pages_route")
is_edit = menu_item is not None
if is_edit:
action_url = qurl("menu_items.update_menu_item_route", item_id=menu_item.id)
action_attr = f'sx-put="{action_url}"'
post_id = str(menu_item.container_id) if menu_item.container_id else ""
label = getattr(menu_item, "label", "") or ""
slug = getattr(menu_item, "slug", "") or ""
fi = getattr(menu_item, "feature_image", None) or ""
else:
action_url = qurl("menu_items.create_menu_item_route")
action_attr = f'sx-post="{action_url}"'
post_id = ""
label = ""
slug = ""
fi = ""
# Build selected page display
if post_id:
img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
else:
selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'
close_js = "document.getElementById('menu-item-form').innerHTML = ''"
title = "Edit Menu Item" if is_edit else "Add Menu Item"
html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">{title}</h2>
<button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
<i class="fa fa-times"></i></button>
</div>
<input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
{selected}
<form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
sx-include="#selected-post-id"
sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
class="space-y-4">
<input type="hidden" name="csrf_token" value="{csrf}">
<div class="flex gap-2 pb-3 border-b">
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fa fa-save"></i> Save</button>
<button type="button" onclick="{close_js}"
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
</div>
</form>
<div class="mt-4">
<label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
<input type="text" placeholder="Search for a page... (or leave blank for all)"
sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
sx-target="#page-search-results" sx-swap="innerHTML"
name="q" id="page-search-input"
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div id="page-search-results" class="mt-2"></div>
</div>
</div>
<script>
document.addEventListener('click', function(e) {{
var pageOption = e.target.closest('[data-page-id]');
if (pageOption) {{
var postId = pageOption.dataset.pageId;
var postTitle = pageOption.dataset.pageTitle;
var postSlug = pageOption.dataset.pageSlug;
var postImage = pageOption.dataset.pageImage;
document.getElementById('selected-post-id').value = postId;
var display = document.getElementById('selected-page-display');
display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
(postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
'<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
display.classList.remove('hidden');
document.getElementById('page-search-results').innerHTML = '';
}}
}});
</script>'''
return html
def render_page_search_results(pages, query, page, has_more) -> str:
"""Render page search results (replaces _types/menu_items/_page_search_results.html)."""
from quart import url_for as qurl
if not pages and query:
return sx_call("page-search-empty", query=query)
if not pages:
return ""
page_dicts = [
dict(id=post.id, title=post.title, slug=post.slug,
feature_image=post.feature_image or None)
for post in pages
]
search_url = qurl("menu_items.search_pages_route") if has_more else None
return sx_call("page-search-results-from-data",
pages=page_dicts, query=query, has_more=has_more,
search_url=search_url, next_page=page + 1)
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")
if ctx is None:
ctx = {}
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
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-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_dicts = []
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"
hx_get = f"/{item_slug}/" if item_slug != "cart" else None
item_dicts.append(dict(
slug=item_slug, label=label, feature_image=fi,
href=href, hx_get=hx_get, selected=selected,
))
return sx_call("blog-menu-nav-from-data",
items=item_dicts, nav_cls=nav_cls, container_id=container_id,
arrow_cls=arrow_cls, scroll_hs=scroll_hs)
# ---- 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,
)