Files
mono/blog/sexp/sexp_components.py
giles beebe559cd Show selected sub-page name in white next to admin label
Appends e.g. "settings" in white text next to the admin shield icon
on the left side of the admin row, in addition to the highlighted
nav button on the right.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:28:27 +00:00

1992 lines
77 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.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
post_header_html as _shared_post_header_html,
oob_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
)
# Load blog service .sexpr component definitions
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# ---------------------------------------------------------------------------
# OOB header helper — delegates to shared
# ---------------------------------------------------------------------------
_oob_header_html = oob_header_html
# ---------------------------------------------------------------------------
# Blog header (root-header-child -> blog-header-child)
# ---------------------------------------------------------------------------
def _blog_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Blog header row — empty child of root."""
return render("menu-row",
id="blog-row", level=1,
link_label_html=render("blog-header-label"),
child_id="blog-header-child", oob=oob,
)
# ---------------------------------------------------------------------------
# Post header helpers — thin wrapper over shared post_header_html
# ---------------------------------------------------------------------------
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row (blog-specific: container-nav wrapping + admin cog)."""
overrides: dict = {}
# Blog wraps container_nav_html in border styling
container_nav = ctx.get("container_nav_html", "")
if container_nav:
overrides["container_nav_html"] = render("blog-container-nav",
container_nav_html=container_nav,
)
# Admin cog link
from quart import url_for as qurl, request
post = ctx.get("post") or {}
slug = post.get("slug", "")
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin and slug:
select_colours = ctx.get("select_colours", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
admin_href = qurl("blog.post.admin.admin", slug=slug)
is_admin_page = "/admin" in request.path
overrides["post_admin_nav_html"] = render("nav-link",
href=admin_href, hx_select="#main-panel", icon="fa fa-cog",
aclass=f"{nav_btn} {select_colours}",
select_colours=select_colours, is_selected=is_admin_page,
)
effective_ctx = {**ctx, **overrides} if overrides else ctx
return _shared_post_header_html(effective_ctx, oob=oob)
# ---------------------------------------------------------------------------
# Post admin header
# ---------------------------------------------------------------------------
def _post_admin_header_html(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
"""Post admin header row with admin icon and nav links."""
from quart import url_for as qurl
post = ctx.get("post") or {}
slug = post.get("slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
admin_href = qurl("blog.post.admin.admin", slug=slug)
label_html = render("blog-admin-label")
if selected:
label_html += f' <span class="text-white">{escape(selected)}</span>'
nav_html = _post_admin_nav_html(ctx, selected=selected)
return render("menu-row",
id="post-admin-row", level=2,
link_href=admin_href, link_label_html=label_html,
nav_html=nav_html, child_id="post-admin-header-child", oob=oob,
)
def _post_admin_nav_html(ctx: dict, *, selected: str = "") -> str:
"""Post admin desktop nav: calendars, markets, payments, entries, data, edit, settings."""
from quart import url_for as qurl
post = ctx.get("post") or {}
slug = post.get("slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
# Base and selected class for nav items
base_cls = "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
selected_cls = "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"
parts = []
# External links to events / market services
events_url_fn = ctx.get("events_url")
market_url_fn = ctx.get("market_url")
if callable(events_url_fn):
for url_fn, path, label in [
(events_url_fn, f"/{slug}/admin/", "calendars"),
(market_url_fn, f"/{slug}/admin/", "markets"),
(ctx.get("cart_url"), f"/{slug}/admin/payments/", "payments"),
]:
if not callable(url_fn):
continue
href = url_fn(path)
cls = selected_cls if label == selected else (nav_btn or base_cls)
parts.append(render("blog-admin-nav-item",
href=href, nav_btn_class=cls, label=label,
select_colours=select_colours,
))
# HTMX links
for endpoint, label in [
("blog.post.admin.entries", "entries"),
("blog.post.admin.data", "data"),
("blog.post.admin.edit", "edit"),
("blog.post.admin.settings", "settings"),
]:
href = qurl(endpoint, slug=slug)
is_sel = label == selected
parts.append(render("nav-link",
href=href, label=label, select_colours=select_colours,
is_selected=is_sel,
aclass=(selected_cls + " " + select_colours) if is_sel else None,
))
return "".join(parts)
# ---------------------------------------------------------------------------
# Settings header (root-header-child -> root-settings-header-child)
# ---------------------------------------------------------------------------
def _settings_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Settings header row with admin icon and nav links."""
from quart import url_for as qurl
hx_select = ctx.get("hx_select_search", "#main-panel")
settings_href = qurl("settings.home")
label_html = render("blog-admin-label")
nav_html = _settings_nav_html(ctx)
return render("menu-row",
id="root-settings-row", level=1,
link_href=settings_href, link_label_html=label_html,
nav_html=nav_html, child_id="root-settings-header-child", oob=oob,
)
def _settings_nav_html(ctx: dict) -> str:
"""Settings desktop nav: menu items, snippets, tag groups, cache."""
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(render("nav-link",
href=href, icon=f"fa fa-{icon}", label=label,
select_colours=select_colours,
))
return "".join(parts)
# ---------------------------------------------------------------------------
# Sub-settings headers (root-settings-header-child -> X-header-child)
# ---------------------------------------------------------------------------
def _sub_settings_header_html(row_id: str, child_id: str, href: str,
icon: str, label: str, ctx: dict,
*, oob: bool = False, nav_html: str = "") -> str:
"""Generic sub-settings header row (menu_items, snippets, tag_groups, cache)."""
select_colours = ctx.get("select_colours", "")
label_html = render("blog-sub-settings-label",
icon=f"fa fa-{icon}", label=label,
)
return render("menu-row",
id=row_id, level=2,
link_href=href, link_label_html=label_html,
nav_html=nav_html, child_id=child_id, oob=oob,
)
def _post_sub_admin_header_html(row_id: str, child_id: str, href: str,
icon: str, label: str, ctx: dict,
*, oob: bool = False, nav_html: str = "") -> str:
"""Generic post sub-admin header row (data, edit, entries, settings)."""
label_html = render("blog-sub-admin-label",
icon=f"fa fa-{icon}", label=label,
)
return render("menu-row",
id=row_id, level=3,
link_href=href, link_label_html=label_html,
nav_html=nav_html, child_id=child_id, oob=oob,
)
# ---------------------------------------------------------------------------
# Blog index main panel helpers
# ---------------------------------------------------------------------------
def _blog_cards_html(ctx: dict) -> str:
"""Render blog post cards (list or tile)."""
posts = ctx.get("posts") or []
view = ctx.get("view")
parts = []
for p in posts:
if view == "tile":
parts.append(_blog_card_tile_html(p, ctx))
else:
parts.append(_blog_card_html(p, ctx))
parts.append(_blog_sentinel_html(ctx))
return "".join(parts)
def _blog_card_html(post: dict, ctx: dict) -> str:
"""Single blog post card (list view)."""
from quart import url_for as qurl, 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)
like_html = ""
if user:
liked = post.get("is_liked", False)
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
like_html = render("blog-like-button",
like_url=like_url,
hx_headers=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}',
heart="\u2764\ufe0f" if liked else "\U0001f90d",
)
status = post.get("status", "published")
status_html = ""
if status == "draft":
pub_req = post.get("publish_requested")
updated = post.get("updated_at")
ts = ""
if updated:
ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated)
status_html = render("blog-draft-status",
publish_requested=pub_req, timestamp=ts,
)
else:
pub = post.get("published_at")
if pub:
ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub)
status_html = render("blog-published-status", timestamp=ts)
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", "")), "")
at_bar = _at_bar_html(post, ctx)
return render("blog-card",
like_html=like_html, href=href, hx_select=hx_select,
title=post.get("title", ""), status_html=status_html,
feature_image=fi, excerpt=excerpt, widget_html=widget,
at_bar_html=at_bar,
)
def _blog_card_tile_html(post: dict, ctx: dict) -> str:
"""Single blog post card (tile view)."""
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")
status_html = ""
if status == "draft":
updated = post.get("updated_at")
ts = ""
if updated:
ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated)
status_html = render("blog-draft-status",
publish_requested=post.get("publish_requested"), timestamp=ts,
)
else:
pub = post.get("published_at")
if pub:
ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub)
status_html = render("blog-published-status", timestamp=ts)
excerpt = post.get("custom_excerpt") or post.get("excerpt", "")
at_bar = _at_bar_html(post, ctx)
return render("blog-card-tile",
href=href, hx_select=hx_select, feature_image=fi,
title=post.get("title", ""), status_html=status_html,
excerpt=excerpt, at_bar_html=at_bar,
)
def _at_bar_html(post: dict, ctx: dict) -> str:
"""Tags + authors bar below a card."""
tags = post.get("tags") or []
authors = post.get("authors") or []
if not tags and not authors:
return ""
tag_items = ""
if tags:
tag_li = []
for t in tags:
t_name = t.get("name") or getattr(t, "name", "")
t_fi = t.get("feature_image") or getattr(t, "feature_image", None)
if t_fi:
icon = render("blog-tag-icon-image", src=t_fi, name=t_name)
else:
init = (t_name[:1]) if t_name else ""
icon = render("blog-tag-icon-initial", initial=init)
tag_li.append(render("blog-tag-li",
icon_html=icon, name=t_name,
))
tag_items = render("blog-tag-bar", items_html="".join(tag_li))
author_items = ""
if authors:
author_li = []
for a in authors:
a_name = a.get("name") or getattr(a, "name", "")
a_img = a.get("profile_image") or getattr(a, "profile_image", None)
if a_img:
author_li.append(render("blog-author-with-image",
image=a_img, name=a_name,
))
else:
author_li.append(render("blog-author-text", name=a_name))
author_items = render("blog-author-bar", items_html="".join(author_li))
return render("blog-at-bar",
tag_items_html=tag_items, author_items_html=author_items,
)
def _blog_sentinel_html(ctx: dict) -> str:
"""Infinite scroll sentinels for blog post list."""
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 render("blog-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()"
)
mobile = render("blog-sentinel-mobile",
id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs,
)
desktop = render("blog-sentinel-desktop",
id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs,
)
return mobile + desktop
def _page_cards_html(ctx: dict) -> str:
"""Render page cards with sentinel."""
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_html(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(render("blog-page-sentinel",
id=f"sentinel-{page_num}-d", next_url=next_url,
))
elif pages:
parts.append(render("blog-end-of-results"))
else:
parts.append(render("blog-no-pages"))
return "".join(parts)
def _page_card_html(page: dict, ctx: dict) -> str:
"""Single page card."""
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 {}
badges_html = ""
if features:
badges_html = render("blog-page-badges",
has_calendar=features.get("calendar"), has_market=features.get("market"),
)
pub = page.get("published_at")
pub_html = ""
if pub:
ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub)
pub_html = render("blog-published-status", timestamp=ts)
fi = page.get("feature_image")
excerpt = page.get("custom_excerpt") or page.get("excerpt", "")
return render("blog-page-card",
href=href, hx_select=hx_select, title=page.get("title", ""),
badges_html=badges_html, pub_html=pub_html, feature_image=fi,
excerpt=excerpt,
)
def _view_toggle_html(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 = render("blog-list-svg")
tile_svg = render("blog-tile-svg")
return render("blog-view-toggle",
list_href=list_href, tile_href=tile_href, hx_select=hx_select,
list_cls=list_cls, tile_cls=tile_cls,
list_svg_html=list_svg, tile_svg_html=tile_svg,
)
def _content_type_tabs_html(ctx: dict) -> str:
"""Posts/Pages tabs."""
from quart import url_for as qurl
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 render("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_html(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_html(ctx)
if content_type == "pages":
cards = _page_cards_html(ctx)
return render("blog-main-panel-pages",
tabs_html=tabs, cards_html=cards,
)
else:
toggle = _view_toggle_html(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_html(ctx)
return render("blog-main-panel-posts",
tabs_html=tabs, toggle_html=toggle, grid_cls=grid_cls,
cards_html=cards,
)
# ---------------------------------------------------------------------------
# Desktop aside (filter sidebar)
# ---------------------------------------------------------------------------
def _blog_aside_html(ctx: dict) -> str:
"""Desktop aside with search, action buttons, and filters."""
sd = search_desktop_html(ctx)
ab = _action_buttons_html(ctx)
tgf = _tag_groups_filter_html(ctx)
af = _authors_filter_html(ctx)
return render("blog-aside",
search_html=sd, action_buttons_html=ab,
tag_groups_filter_html=tgf, authors_filter_html=af,
)
def _blog_filter_html(ctx: dict) -> str:
"""Mobile filter (details/summary)."""
current_local_href = ctx.get("current_local_href", "/index")
search = ctx.get("search", "")
search_count = ctx.get("search_count", "")
hx_select = ctx.get("hx_select", "#main-panel")
# Mobile filter summary tags
summary_parts = []
summary_parts.append(_tag_groups_filter_summary_html(ctx))
summary_parts.append(_authors_filter_summary_html(ctx))
summary_html = "".join(summary_parts)
filter_content = search_mobile_html(ctx) + summary_html
action_buttons = _action_buttons_html(ctx)
filter_details = _tag_groups_filter_html(ctx) + _authors_filter_html(ctx)
return render("mobile-filter",
filter_summary_html=filter_content,
action_buttons_html=action_buttons,
filter_details_html=filter_details,
)
def _action_buttons_html(ctx: dict) -> str:
"""New Post/Page + Drafts toggle buttons."""
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(render("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(render("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(render("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(render("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)
return render("blog-action-buttons-wrapper", inner_html=inner)
def _tag_groups_filter_html(ctx: dict) -> str:
"""Tag group filter bar for desktop/mobile."""
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 = [render("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 = render("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 = render("blog-filter-group-icon-color", style=style, initial=g_name[:1])
li_parts.append(render("blog-filter-group-li",
cls=cls, hx_get=f"?group={g_slug}&page=1", hx_select=hx_select,
icon_html=icon, name=g_name, count=str(g_count),
))
items = "".join(li_parts)
return render("blog-filter-nav", items_html=items)
def _authors_filter_html(ctx: dict) -> str:
"""Author filter bar for desktop/mobile."""
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 = [render("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 = ""
if a_img:
icon = render("blog-filter-author-icon", src=a_img, name=a_name)
li_parts.append(render("blog-filter-author-li",
cls=cls, hx_get=f"?author={a_slug}&page=1", hx_select=hx_select,
icon_html=icon, name=a_name, count=str(a_count),
))
items = "".join(li_parts)
return render("blog-filter-nav", items_html=items)
def _tag_groups_filter_summary_html(ctx: dict) -> str:
"""Mobile filter summary for tag groups."""
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 render("blog-filter-summary", text=", ".join(names))
def _authors_filter_summary_html(ctx: dict) -> str:
"""Mobile filter summary for authors."""
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 render("blog-filter-summary", text=", ".join(names))
# ---------------------------------------------------------------------------
# Post detail main panel
# ---------------------------------------------------------------------------
def _post_main_panel_html(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_html = ""
if post.get("status") == "draft":
edit_html = ""
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
edit_href = qurl("blog.post.admin.edit", slug=slug)
edit_html = render("blog-detail-edit-link",
href=edit_href, hx_select=hx_select,
)
draft_html = render("blog-detail-draft",
publish_requested=post.get("publish_requested"), edit_html=edit_html,
)
# Blog post chrome (not for pages)
chrome_html = ""
if not post.get("is_page"):
like_html = ""
if user:
liked = post.get("is_liked", False)
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
like_html = render("blog-detail-like",
like_url=like_url,
hx_headers=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}',
heart="\u2764\ufe0f" if liked else "\U0001f90d",
)
excerpt_html = ""
if post.get("custom_excerpt"):
excerpt_html = render("blog-detail-excerpt",
excerpt=post["custom_excerpt"],
)
at_bar = _at_bar_html(post, ctx)
chrome_html = render("blog-detail-chrome",
like_html=like_html, excerpt_html=excerpt_html, at_bar_html=at_bar,
)
fi = post.get("feature_image")
html_content = post.get("html", "")
return render("blog-detail-main",
draft_html=draft_html, chrome_html=chrome_html,
feature_image=fi, html_content=html_content,
)
def _post_meta_html(ctx: dict) -> str:
"""Post SEO meta tags (Open Graph, Twitter, JSON-LD)."""
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 render("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_html(ctx: dict) -> str:
"""Home page content — renders the Ghost page HTML."""
post = ctx.get("post") or {}
html = post.get("html", "")
return render("blog-home-main", html_content=html)
# ---------------------------------------------------------------------------
# Post admin - empty main panel
# ---------------------------------------------------------------------------
def _post_admin_main_panel_html(ctx: dict) -> str:
return render("blog-admin-empty")
# ---------------------------------------------------------------------------
# Settings main panels
# ---------------------------------------------------------------------------
def _settings_main_panel_html(ctx: dict) -> str:
return render("blog-settings-empty")
def _cache_main_panel_html(ctx: dict) -> str:
from quart import url_for as qurl
csrf = ctx.get("csrf_token", "")
clear_url = qurl("settings.cache_clear")
return render("blog-cache-panel", clear_url=clear_url, csrf=csrf)
# ---------------------------------------------------------------------------
# Snippets main panel
# ---------------------------------------------------------------------------
def _snippets_main_panel_html(ctx: dict) -> str:
sl = _snippets_list_html(ctx)
return render("blog-snippets-panel", list_html=sl)
def _snippets_list_html(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.get("csrf_token", "")
user = getattr(g, "user", None)
user_id = getattr(user, "id", None)
if not snippets:
return render("blog-snippets-empty")
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 += render("blog-snippet-option",
value=v, selected=(s_vis == v), label=v,
)
extra += render("blog-snippet-visibility-select",
patch_url=patch_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
options_html=opts,
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 += render("blog-snippet-delete-button",
confirm_text=f'Delete \u201c{s_name}\u201d?',
delete_url=del_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
)
row_parts.append(render("blog-snippet-row",
name=s_name, owner=owner, badge_cls=badge_cls,
visibility=s_vis, extra_html=extra,
))
rows = "".join(row_parts)
return render("blog-snippets-list", rows_html=rows)
# ---------------------------------------------------------------------------
# Menu items main panel
# ---------------------------------------------------------------------------
def _menu_items_main_panel_html(ctx: dict) -> str:
from quart import url_for as qurl
new_url = qurl("menu_items.new_menu_item")
ml = _menu_items_list_html(ctx)
return render("blog-menu-items-panel", new_url=new_url, list_html=ml)
def _menu_items_list_html(ctx: dict) -> str:
from quart import url_for as qurl
menu_items = ctx.get("menu_items") or []
csrf = ctx.get("csrf_token", "")
if not menu_items:
return render("blog-menu-items-empty")
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_html = render("blog-menu-item-image", src=fi, label=label)
row_parts.append(render("blog-menu-item-row",
img_html=img_html, 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 render("blog-menu-items-list", rows_html=rows)
# ---------------------------------------------------------------------------
# Tag groups main panel
# ---------------------------------------------------------------------------
def _tag_groups_main_panel_html(ctx: dict) -> str:
from quart import url_for as qurl
groups = ctx.get("groups") or []
unassigned_tags = ctx.get("unassigned_tags") or []
csrf = ctx.get("csrf_token", "")
create_url = qurl("blog.tag_groups_admin.create")
form_html = render("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 = render("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 = render("blog-tag-group-icon-color", style=style, initial=g_name[:1])
li_parts.append(render("blog-tag-group-li",
icon_html=icon, edit_href=edit_href, name=g_name,
slug=g_slug, sort_order=str(g_sort),
))
groups_html = render("blog-tag-groups-list", items_html="".join(li_parts))
else:
groups_html = render("blog-tag-groups-empty")
# Unassigned tags
unassigned_html = ""
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(render("blog-unassigned-tag", name=t_name))
unassigned_html = render("blog-unassigned-tags",
heading=f"Unassigned Tags ({len(unassigned_tags)})",
spans_html="".join(tag_spans),
)
return render("blog-tag-groups-main",
form_html=form_html, groups_html=groups_html,
unassigned_html=unassigned_html,
)
def _tag_groups_edit_main_panel_html(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.get("csrf_token", "")
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 = render("blog-tag-checkbox-image", src=t_fi) if t_fi else ""
tag_items.append(render("blog-tag-checkbox",
tag_id=str(t_id), checked=checked, img_html=img, name=t_name,
))
edit_form = render("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_html="".join(tag_items),
)
del_form = render("blog-tag-group-delete-form",
delete_url=del_url, csrf=csrf,
)
return render("blog-tag-group-edit-main",
edit_form_html=edit_form, delete_form_html=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_html(ctx)
post_hdr = _post_header_html(ctx)
header_rows = root_hdr + post_hdr
content = _home_main_panel_html(ctx)
meta = _post_meta_html(ctx)
menu_html = ctx.get("nav_html", "") or ""
return full_page(ctx, header_rows_html=header_rows, content_html=content,
meta_html=meta, menu_html=menu_html)
async def render_home_oob(ctx: dict) -> str:
root_hdr = root_header_html(ctx, oob=True)
post_oob = _oob_header_html("root-header-child", "post-header-child",
_post_header_html(ctx))
content = _home_main_panel_html(ctx)
return oob_page(ctx, oobs_html=root_hdr + post_oob, content_html=content)
# ---- Blog index ----
async def render_blog_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
blog_hdr = _blog_header_html(ctx)
header_rows = root_hdr + blog_hdr
content = _blog_main_panel_html(ctx)
aside = _blog_aside_html(ctx)
filter_html = _blog_filter_html(ctx)
return full_page(ctx, header_rows_html=header_rows, content_html=content,
aside_html=aside, filter_html=filter_html)
async def render_blog_oob(ctx: dict) -> str:
root_hdr = root_header_html(ctx, oob=True)
blog_oob = _oob_header_html("root-header-child", "blog-header-child",
_blog_header_html(ctx))
content = _blog_main_panel_html(ctx)
aside = _blog_aside_html(ctx)
filter_html = _blog_filter_html(ctx)
nav_html = ctx.get("nav_html", "") or ""
return oob_page(ctx, oobs_html=root_hdr + blog_oob,
content_html=content, aside_html=aside,
filter_html=filter_html, menu_html=nav_html)
async def render_blog_cards(ctx: dict) -> str:
"""Pagination-only response (page > 1)."""
return _blog_cards_html(ctx)
async def render_blog_page_cards(ctx: dict) -> str:
"""Page cards pagination response."""
return _page_cards_html(ctx)
# ---- New post/page editor panel ----
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
"""Build the WYSIWYG editor panel HTML (replaces _main_panel.html template).
This is synchronous — it just assembles an HTML string from the current
request context (url_for, CSRF token, asset URLs, config).
"""
import os
from quart import url_for as qurl, current_app
from shared.browser.app.csrf import generate_csrf_token
from markupsafe import escape as esc
csrf = generate_csrf_token()
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
editor_css = asset_url_fn("scripts/editor.css")
editor_js = asset_url_fn("scripts/editor.js")
upload_image_url = qurl("blog.editor_api.upload_image")
upload_media_url = qurl("blog.editor_api.upload_media")
upload_file_url = qurl("blog.editor_api.upload_file")
oembed_url = qurl("blog.editor_api.oembed_proxy")
snippets_url = qurl("blog.editor_api.list_snippets")
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")
title_placeholder = "Page title..." if is_page else "Post title..."
create_label = "Create Page" if is_page else "Create Post"
parts: list[str] = []
# Error banner
if save_error:
parts.append(render("blog-editor-error", error=str(save_error)))
# Form structure
form_html = render("blog-editor-form",
csrf=csrf, title_placeholder=title_placeholder,
create_label=create_label,
)
parts.append(form_html)
# Editor CSS + inline styles
parts.append(render("blog-editor-styles", css_href=editor_css))
# Editor JS + init script
init_js = (
"(function() {\n"
" function applyEditorFontSize() {\n"
" document.documentElement.style.fontSize = '62.5%';\n"
" document.body.style.fontSize = '1.6rem';\n"
" }\n"
" function restoreDefaultFontSize() {\n"
" document.documentElement.style.fontSize = '';\n"
" document.body.style.fontSize = '';\n"
" }\n"
" applyEditorFontSize();\n"
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {\n"
" if (e.detail.target && e.detail.target.id === 'main-panel') {\n"
" restoreDefaultFontSize();\n"
" document.body.removeEventListener('htmx:beforeSwap', cleanup);\n"
" }\n"
" });\n"
"\n"
" function init() {\n"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
f" var uploadUrl = '{upload_image_url}';\n"
" var uploadUrls = {\n"
" image: uploadUrl,\n"
f" media: '{upload_media_url}',\n"
f" file: '{upload_file_url}',\n"
" };\n"
"\n"
" var fileInput = document.getElementById('feature-image-file');\n"
" var addBtn = document.getElementById('feature-image-add-btn');\n"
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
" var preview = document.getElementById('feature-image-preview');\n"
" var emptyState = document.getElementById('feature-image-empty');\n"
" var filledState = document.getElementById('feature-image-filled');\n"
" var hiddenUrl = document.getElementById('feature-image-input');\n"
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
" var captionInput = document.getElementById('feature-image-caption');\n"
" var uploading = document.getElementById('feature-image-uploading');\n"
"\n"
" function showFilled(url) {\n"
" preview.src = url;\n"
" hiddenUrl.value = url;\n"
" emptyState.classList.add('hidden');\n"
" filledState.classList.remove('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function showEmpty() {\n"
" preview.src = '';\n"
" hiddenUrl.value = '';\n"
" hiddenCaption.value = '';\n"
" captionInput.value = '';\n"
" emptyState.classList.remove('hidden');\n"
" filledState.classList.add('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function uploadFile(file) {\n"
" emptyState.classList.add('hidden');\n"
" uploading.classList.remove('hidden');\n"
" var fd = new FormData();\n"
" fd.append('file', file);\n"
" fetch(uploadUrl, {\n"
" method: 'POST',\n"
" body: fd,\n"
" headers: { 'X-CSRFToken': csrfToken },\n"
" })\n"
" .then(function(r) {\n"
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
" return r.json();\n"
" })\n"
" .then(function(data) {\n"
" var url = data.images && data.images[0] && data.images[0].url;\n"
" if (url) showFilled(url);\n"
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
" })\n"
" .catch(function(e) {\n"
" showEmpty();\n"
" alert(e.message);\n"
" });\n"
" }\n"
"\n"
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
" preview.addEventListener('click', function() { fileInput.click(); });\n"
" deleteBtn.addEventListener('click', function(e) {\n"
" e.stopPropagation();\n"
" showEmpty();\n"
" });\n"
" fileInput.addEventListener('change', function() {\n"
" if (fileInput.files && fileInput.files[0]) {\n"
" uploadFile(fileInput.files[0]);\n"
" fileInput.value = '';\n"
" }\n"
" });\n"
" captionInput.addEventListener('input', function() {\n"
" hiddenCaption.value = captionInput.value;\n"
" });\n"
"\n"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
" function autoResize() {\n"
" excerpt.style.height = 'auto';\n"
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
" }\n"
" excerpt.addEventListener('input', autoResize);\n"
" autoResize();\n"
"\n"
" window.mountEditor('lexical-editor', {\n"
" initialJson: null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
f" unsplashApiKey: '{unsplash_key}',\n"
f" snippetsUrl: '{snippets_url}',\n"
" });\n"
"\n"
" document.addEventListener('keydown', function(e) {\n"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
" e.preventDefault();\n"
" document.getElementById('post-new-form').requestSubmit();\n"
" }\n"
" });\n"
" }\n"
"\n"
" if (typeof window.mountEditor === 'function') {\n"
" init();\n"
" } else {\n"
" var _t = setInterval(function() {\n"
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
" }, 50);\n"
" }\n"
"})();\n"
)
parts.append(render("blog-editor-scripts", js_src=editor_js, init_js=init_js))
return "".join(parts)
# ---- New post/page ----
async def render_new_post_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
blog_hdr = _blog_header_html(ctx)
header_rows = root_hdr + blog_hdr
content = ctx.get("editor_html", "")
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_new_post_oob(ctx: dict) -> str:
root_hdr = root_header_html(ctx, oob=True)
blog_oob = _blog_header_html(ctx, oob=True)
content = ctx.get("editor_html", "")
return oob_page(ctx, oobs_html=root_hdr + blog_oob, content_html=content)
# ---- Post detail ----
async def render_post_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
header_rows = root_hdr + post_hdr
content = _post_main_panel_html(ctx)
meta = _post_meta_html(ctx)
menu_html = ctx.get("nav_html", "") or ""
return full_page(ctx, header_rows_html=header_rows, content_html=content,
meta_html=meta, menu_html=menu_html)
async def render_post_oob(ctx: dict) -> str:
root_hdr = root_header_html(ctx, oob=True)
post_oob = _oob_header_html("root-header-child", "post-header-child",
_post_header_html(ctx))
content = _post_main_panel_html(ctx)
menu_html = ctx.get("nav_html", "") or ""
return oob_page(ctx, oobs_html=root_hdr + post_oob,
content_html=content, menu_html=menu_html)
# ---- Post admin ----
async def render_post_admin_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = _post_admin_header_html(ctx)
header_rows = root_hdr + post_hdr + admin_hdr
content = _post_admin_main_panel_html(ctx)
menu_html = ctx.get("nav_html", "") or ""
return full_page(ctx, header_rows_html=header_rows, content_html=content,
menu_html=menu_html)
async def render_post_admin_oob(ctx: dict) -> str:
post_hdr_oob = _post_header_html(ctx, oob=True)
admin_oob = _oob_header_html("post-header-child", "post-admin-header-child",
_post_admin_header_html(ctx))
content = _post_admin_main_panel_html(ctx)
menu_html = ctx.get("nav_html", "") or ""
return oob_page(ctx, oobs_html=post_hdr_oob + admin_oob,
content_html=content, menu_html=menu_html)
# ---- Post data ----
async def render_post_data_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = _post_admin_header_html(ctx, selected="data")
header_rows = root_hdr + post_hdr + admin_hdr
content = ctx.get("data_html", "")
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_post_data_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="data")
content = ctx.get("data_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
# ---- Post entries ----
async def render_post_entries_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = _post_admin_header_html(ctx, selected="entries")
header_rows = root_hdr + post_hdr + admin_hdr
content = ctx.get("entries_html", "")
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_post_entries_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="entries")
content = ctx.get("entries_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
# ---- Post edit ----
async def render_post_edit_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = _post_admin_header_html(ctx, selected="edit")
header_rows = root_hdr + post_hdr + admin_hdr
content = ctx.get("edit_html", "")
body_end = ctx.get("body_end_html", "")
return full_page(ctx, header_rows_html=header_rows, content_html=content,
body_end_html=body_end)
async def render_post_edit_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="edit")
content = ctx.get("edit_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
# ---- Post settings ----
async def render_post_settings_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
post_hdr = _post_header_html(ctx)
admin_hdr = _post_admin_header_html(ctx, selected="settings")
header_rows = root_hdr + post_hdr + admin_hdr
content = ctx.get("settings_html", "")
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_post_settings_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="settings")
content = ctx.get("settings_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
# ---- Settings home ----
async def render_settings_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
settings_hdr = _settings_header_html(ctx)
header_rows = root_hdr + settings_hdr
content = _settings_main_panel_html(ctx)
menu_html = _settings_nav_html(ctx)
return full_page(ctx, header_rows_html=header_rows, content_html=content,
menu_html=menu_html)
async def render_settings_oob(ctx: dict) -> str:
root_hdr = root_header_html(ctx, oob=True)
settings_oob = _oob_header_html("root-header-child", "root-settings-header-child",
_settings_header_html(ctx))
content = _settings_main_panel_html(ctx)
menu_html = _settings_nav_html(ctx)
return oob_page(ctx, oobs_html=root_hdr + settings_oob,
content_html=content, menu_html=menu_html)
# ---- Cache ----
async def render_cache_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
settings_hdr = _settings_header_html(ctx)
from quart import url_for as qurl
cache_hdr = _sub_settings_header_html(
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
header_rows = root_hdr + settings_hdr + cache_hdr
content = _cache_main_panel_html(ctx)
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_cache_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_html(ctx, oob=True)
from quart import url_for as qurl
cache_hdr = _sub_settings_header_html(
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
cache_oob = _oob_header_html("root-settings-header-child", "cache-header-child",
cache_hdr)
content = _cache_main_panel_html(ctx)
return oob_page(ctx, oobs_html=settings_hdr_oob + cache_oob, content_html=content)
# ---- Snippets ----
async def render_snippets_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
settings_hdr = _settings_header_html(ctx)
from quart import url_for as qurl
snippets_hdr = _sub_settings_header_html(
"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_html(ctx)
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_snippets_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_html(ctx, oob=True)
from quart import url_for as qurl
snippets_hdr = _sub_settings_header_html(
"snippets-row", "snippets-header-child",
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
)
snippets_oob = _oob_header_html("root-settings-header-child", "snippets-header-child",
snippets_hdr)
content = _snippets_main_panel_html(ctx)
return oob_page(ctx, oobs_html=settings_hdr_oob + snippets_oob, content_html=content)
# ---- Menu items ----
async def render_menu_items_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
settings_hdr = _settings_header_html(ctx)
from quart import url_for as qurl
mi_hdr = _sub_settings_header_html(
"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_html(ctx)
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_menu_items_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_html(ctx, oob=True)
from quart import url_for as qurl
mi_hdr = _sub_settings_header_html(
"menu_items-row", "menu_items-header-child",
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
)
mi_oob = _oob_header_html("root-settings-header-child", "menu_items-header-child",
mi_hdr)
content = _menu_items_main_panel_html(ctx)
return oob_page(ctx, oobs_html=settings_hdr_oob + mi_oob, content_html=content)
# ---- Tag groups ----
async def render_tag_groups_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
settings_hdr = _settings_header_html(ctx)
from quart import url_for as qurl
tg_hdr = _sub_settings_header_html(
"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_html(ctx)
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_tag_groups_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_html(ctx, oob=True)
from quart import url_for as qurl
tg_hdr = _sub_settings_header_html(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
)
tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child",
tg_hdr)
content = _tag_groups_main_panel_html(ctx)
return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content)
# ---- Tag group edit ----
async def render_tag_group_edit_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
settings_hdr = _settings_header_html(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_html(
"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_html(ctx)
return full_page(ctx, header_rows_html=header_rows, content_html=content)
async def render_tag_group_edit_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_html(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_html(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
)
tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child",
tg_hdr)
content = _tag_groups_edit_main_panel_html(ctx)
return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content)
# ===========================================================================
# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers
# ===========================================================================
# ---- Like toggle button (delegates to market impl) ----
def render_like_toggle_button(slug: str, liked: bool, like_url: str) -> str:
"""Render a like toggle button for HTMX POST response."""
from market.sexp.sexp_components import render_like_toggle_button as _market_like
return _market_like(slug, liked, like_url=like_url, item_type="post")
# ---- Snippets list ----
def render_snippets_list(snippets, is_admin: bool) -> str:
"""Render the snippets list fragment for HTMX DELETE/PATCH responses."""
from shared.browser.app.csrf import generate_csrf_token
from quart import g
ctx = {
"snippets": snippets,
"is_admin": is_admin,
"csrf_token": generate_csrf_token(),
}
return _snippets_list_html(ctx)
# ---- Menu items list + nav OOB ----
def render_menu_items_list(menu_items) -> str:
"""Render the menu items list fragment for HTMX responses."""
from shared.browser.app.csrf import generate_csrf_token
ctx = {
"menu_items": menu_items,
"csrf_token": generate_csrf_token(),
}
return _menu_items_list_html(ctx)
def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
"""Render the OOB nav update for menu items.
Produces the same DOM structure as ``_types/menu_items/_nav_oob.html``:
a scrolling nav wrapper with ``id="menu-items-nav-wrapper"`` and
``hx-swap-oob="outerHTML"``.
"""
from quart import request as qrequest
if not menu_items:
return render("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_html = render("blog-nav-item-image", src=fi, label=label)
if item_slug != "cart":
item_parts.append(render("blog-nav-item-link",
href=href, hx_get=f"/{item_slug}/", selected=selected,
nav_cls=nav_button_cls, img_html=img_html, label=label,
))
else:
item_parts.append(render("blog-nav-item-plain",
href=href, selected=selected, nav_cls=nav_button_cls,
img_html=img_html, label=label,
))
items_html = "".join(item_parts)
return render("blog-nav-wrapper",
arrow_cls=arrow_cls, container_id=container_id,
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_html=items_html,
)
# ---- 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_html = render("blog-features-form",
features_url=features_url,
calendar_checked=bool(features.get("calendar")),
market_checked=bool(features.get("market")),
hs_trigger=hs_trigger,
)
sumup_html = ""
if features.get("calendar") or features.get("market"):
placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..."
connected = render("blog-sumup-connected") if sumup_configured else ""
key_hint = render("blog-sumup-key-hint") if sumup_configured else ""
sumup_html = render("blog-sumup-form",
sumup_url=sumup_url, merchant_code=sumup_merchant_code,
placeholder=placeholder, key_hint_html=key_hint,
checkout_prefix=sumup_checkout_prefix, connected_html=connected,
)
return render("blog-features-panel",
form_html=form_html, sumup_html=sumup_html,
)
# ---- 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_html = ""
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(render("blog-market-item",
name=m_name, slug=m_slug, delete_url=del_url,
confirm_text=f"Delete market '{m_name}'?",
))
list_html = render("blog-markets-list", items_html="".join(li_parts))
else:
list_html = render("blog-markets-empty")
return render("blog-markets-panel",
list_html=list_html, 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_html = render("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(render("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_html=img_html, name=e_name,
date_str=f"{cal_name} \u2022 {date_str}",
))
if has_entries:
content_html = render("blog-associated-entries-content",
items_html="".join(entry_items),
)
else:
content_html = render("blog-associated-entries-empty")
return render("blog-associated-entries-panel", content_html=content_html)
# ---- 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 render("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(render("blog-nav-entry-item",
href=href, nav_cls=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(render("blog-nav-calendar-item",
href=href, nav_cls=nav_cls, name=cal_name,
))
items_html = "".join(item_parts)
return render("blog-nav-entries-wrapper",
scroll_hs=scroll_hs, items_html=items_html,
)