Files
mono/blog/sexp/sexp_components.py
giles eda95ec58b Enable cross-subdomain htmx and purify layout to sexp
- Disable htmx selfRequestsOnly, add CORS headers for *.rose-ash.com
- Remove same-origin guards from ~menu-row and ~nav-link htmx attrs
- Convert ~app-layout from string-concatenated HTML to pure sexp tree
- Extract ~app-head component, replace ~app-shell with inline structure
- Convert hamburger SVG from Python HTML constant to ~hamburger sexp component
- Fix cross-domain fragment URLs (events_url, market_url)
- Fix starts-with? primitive to handle nil values
- Fix duplicate admin menu rows on OOB swaps
- Add calendar admin nav links (slots, description)
- Convert slots page from Jinja to sexp rendering
- Disable page caching in development mode
- Backfill migration to clean orphaned container_relations

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

2536 lines
111 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
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import sexp
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
)
# ---------------------------------------------------------------------------
# OOB header helper
# ---------------------------------------------------------------------------
def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
"""Wrap a header row in OOB div with child placeholder."""
return (
f'<div id="{parent_id}" hx-swap-oob="outerHTML" class="w-full">'
f'<div class="w-full">{row_html}'
f'<div id="{child_id}"></div></div></div>'
)
# ---------------------------------------------------------------------------
# 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 sexp(
'(~menu-row :id "blog-row" :level 1'
' :link-label-html llh'
' :child-id "blog-header-child" :oob oob)',
llh="<div></div>",
oob=oob,
)
# ---------------------------------------------------------------------------
# Post header helpers
# ---------------------------------------------------------------------------
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image")
label_parts = []
if feature_image:
label_parts.append(
f'<img src="{feature_image}" class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
)
label_parts.append(f"<span>{escape(title)}</span>")
label_html = "".join(label_parts)
nav_parts = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(
f'<a href="{cart_href}" class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full'
f' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition">'
f'<i class="fa fa-shopping-cart" aria-hidden="true"></i>'
f'<span>{page_cart_count}</span></a>'
)
# Container nav fragments (calendars, markets)
container_nav = ctx.get("container_nav_html", "")
if container_nav:
nav_parts.append(
f'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' id="entries-calendars-nav-wrapper">{container_nav}</div>'
)
# Admin link
from quart import url_for as qurl, g, request
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin:
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
sel_attr = ' aria-selected="true"' if is_admin_page else ''
nav_parts.append(
f'<div class="relative nav-group"><a href="{admin_href}"'
f' hx-get="{admin_href}" hx-target="#main-panel" hx-select="#main-panel"'
f' hx-swap="outerHTML" hx-push-url="true"'
f'{sel_attr}'
f' class="{nav_btn} {select_colours}">'
f'<i class="fa fa-cog" aria-hidden="true"></i></a></div>'
)
nav_html = "".join(nav_parts)
link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sexp(
'(~menu-row :id "post-row" :level 1'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id "post-header-child" :oob oob)',
lh=link_href,
llh=label_html,
nh=nav_html,
oob=oob,
)
# ---------------------------------------------------------------------------
# Post admin header
# ---------------------------------------------------------------------------
def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> 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 = '<i class="fa fa-shield-halved" aria-hidden="true"></i> admin'
nav_html = _post_admin_nav_html(ctx)
return sexp(
'(~menu-row :id "post-admin-row" :level 2'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id "post-admin-header-child" :oob oob)',
lh=admin_href,
llh=label_html,
nh=nav_html,
oob=oob,
)
def _post_admin_nav_html(ctx: dict) -> 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", "")
parts = []
# External links to events service
events_url_fn = ctx.get("events_url")
if callable(events_url_fn):
for path, label in [
(f"/{slug}/calendars/", "calendars"),
(f"/{slug}/markets/", "markets"),
(f"/{slug}/payments/", "payments"),
]:
href = events_url_fn(path)
parts.append(
f'<div class="relative nav-group">'
f'<a href="{href}" class="{nav_btn}">{label}</a></div>'
)
# 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)
parts.append(sexp(
'(~nav-link :href h :label l :select-colours sc)',
h=href, l=label, sc=select_colours,
))
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 = '<i class="fa fa-shield-halved" aria-hidden="true"></i> admin'
nav_html = _settings_nav_html(ctx)
return sexp(
'(~menu-row :id "root-settings-row" :level 1'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id "root-settings-header-child" :oob oob)',
lh=settings_href,
llh=label_html,
nh=nav_html,
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(sexp(
'(~nav-link :href h :icon ic :label l :select-colours sc)',
h=href, ic=f"fa fa-{icon}", l=label, sc=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 = f'<i class="fa fa-{icon}" aria-hidden="true"></i> {escape(label)}'
return sexp(
'(~menu-row :id rid :level 2'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id cid :oob oob)',
rid=row_id,
lh=href,
llh=label_html,
nh=nav_html,
cid=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 = f'<i class="fa fa-{icon}" aria-hidden="true"></i><div>{escape(label)}</div>'
return sexp(
'(~menu-row :id rid :level 3'
' :link-href lh :link-label-html llh'
' :nav-html nh :child-id cid :oob oob)',
rid=row_id,
lh=href,
llh=label_html,
nh=nav_html,
cid=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)
parts = ['<article class="border-b pb-6 last:border-b-0 relative">']
# Like button
if user:
liked = post.get("is_liked", False)
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
parts.append(
f'<div class="absolute top-20 right-2 z-10 text-6xl md:text-4xl">'
f'<button hx-post="{like_url}" hx-swap="outerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{ctx.get("csrf_token", "")}\"}}\''
f' class="cursor-pointer">'
f'{"❤️" if liked else "🤍"}</button></div>'
)
parts.append(
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden">'
)
# Header
parts.append(f'<header class="mb-2 text-center"><h2 class="text-4xl font-bold text-stone-900">{escape(post.get("title", ""))}</h2>')
status = post.get("status", "published")
if status == "draft":
parts.append('<div class="flex justify-center gap-2 mt-1">')
parts.append('<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>')
if post.get("publish_requested"):
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</span>')
parts.append('</div>')
updated = post.get("updated_at")
if updated:
ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated)
parts.append(f'<p class="text-sm text-stone-500">Updated: {ts}</p>')
else:
pub = post.get("published_at")
if pub:
ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub)
parts.append(f'<p class="text-sm text-stone-500">Published: {ts}</p>')
parts.append('</header>')
# Feature image
fi = post.get("feature_image")
if fi:
parts.append(f'<div class="mb-4"><img src="{fi}" alt="" class="rounded-lg w-full object-cover"></div>')
# Excerpt
excerpt = post.get("custom_excerpt") or post.get("excerpt", "")
if excerpt:
parts.append(f'<p class="text-stone-700 text-lg leading-relaxed text-center">{escape(excerpt)}</p>')
parts.append('</a>')
# Card widgets (fragments)
card_widgets = ctx.get("card_widgets_html") or {}
widget = card_widgets.get(str(post.get("id", "")), "")
if widget:
parts.append(widget)
# Tags + authors bar
parts.append(_at_bar_html(post, ctx))
parts.append('</article>')
return "".join(parts)
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")
parts = ['<article class="relative">']
parts.append(
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden">'
)
fi = post.get("feature_image")
if fi:
parts.append(f'<div><img src="{fi}" alt="" class="w-full aspect-video object-cover"></div>')
parts.append('<div class="p-3 text-center">')
parts.append(f'<h2 class="text-lg font-bold text-stone-900">{escape(post.get("title", ""))}</h2>')
status = post.get("status", "published")
if status == "draft":
parts.append('<div class="flex justify-center gap-1 mt-1">')
parts.append('<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>')
if post.get("publish_requested"):
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</span>')
parts.append('</div>')
updated = post.get("updated_at")
if updated:
ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated)
parts.append(f'<p class="text-sm text-stone-500">Updated: {ts}</p>')
else:
pub = post.get("published_at")
if pub:
ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub)
parts.append(f'<p class="text-sm text-stone-500">Published: {ts}</p>')
excerpt = post.get("custom_excerpt") or post.get("excerpt", "")
if excerpt:
parts.append(f'<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">{escape(excerpt)}</p>')
parts.append('</div></a>')
parts.append(_at_bar_html(post, ctx))
parts.append('</article>')
return "".join(parts)
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 ""
all_tags = ctx.get("tags") or []
tag_slugs = {t.get("slug") or getattr(t, "slug", "") for t in all_tags} if all_tags else set()
parts = ['<div class="flex flex-row justify-center gap-3">']
if tags:
parts.append('<div class="mt-4 flex items-center gap-2"><div>in</div><ul class="flex flex-wrap gap-2 text-sm">')
for t in tags:
t_slug = t.get("slug") or getattr(t, "slug", "")
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 = f'<img src="{t_fi}" alt="{escape(t_name)}" class="h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0">'
else:
init = escape(t_name[:1]) if t_name else ""
icon = (
f'<div class="h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center'
f' border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600">{init}</div>'
)
parts.append(
f'<li><a class="flex items-center gap-1">{icon}'
f'<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium'
f' border border-stone-200">{escape(t_name)}</span></a></li>'
)
parts.append('</ul></div>')
parts.append('<div></div>')
if authors:
parts.append('<div class="mt-4 flex items-center gap-2"><div>by</div><ul class="flex flex-wrap gap-2 text-sm">')
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:
parts.append(
f'<li class="flex items-center gap-1">'
f'<img src="{a_img}" alt="{escape(a_name)}" class="h-5 w-5 rounded-full object-cover">'
f'<span class="text-stone-700">{escape(a_name)}</span></li>'
)
else:
parts.append(f'<li class="text-stone-700">{escape(a_name)}</li>')
parts.append('</ul></div>')
parts.append('</div>')
return "".join(parts)
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 '<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>'
current_local_href = ctx.get("current_local_href", "/index")
qs_fn = ctx.get("qs")
# Build next page URL
next_url = f"{current_local_href}?page={page + 1}"
parts = []
# Mobile sentinel
parts.append(
f'<div id="sentinel-{page}-m" class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"'
f' hx-get="{next_url}" hx-trigger="intersect once delay:250ms, sentinelmobile:retry"'
f' hx-swap="outerHTML"'
f' _="init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end'
f' if window.matchMedia(\'(min-width: 768px)\').matches then set @hx-disabled to \'\' end'
f' on resize from window if window.matchMedia(\'(min-width: 768px)\').matches then set @hx-disabled to \'\' else remove @hx-disabled end'
f' on htmx:beforeRequest if window.matchMedia(\'(min-width: 768px)\').matches then halt end'
f' add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me'
f' def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end'
f' add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me'
f' 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'
f' on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"'
f' role="status" aria-live="polite" aria-hidden="true">'
f'<div class="js-loading hidden flex justify-center py-8"><div class="animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"></div></div>'
f'<div class="js-neterr hidden text-center py-8 text-stone-400"><i class="fa fa-exclamation-triangle text-2xl"></i><p class="mt-2">Loading failed — retrying…</p></div>'
f'</div>'
)
# Desktop sentinel
parts.append(
f'<div id="sentinel-{page}-d" class="hidden md:block h-4 opacity-0 pointer-events-none"'
f' hx-get="{next_url}" hx-trigger="intersect once delay:250ms, sentinel:retry"'
f' hx-swap="outerHTML"'
f' _="init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end'
f' on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me'
f' remove .opacity-100 from me add .opacity-0 to me'
f' set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end'
f' if trig and trig.type is \'intersect\' set scroller to the closest .js-grid-viewport'
f' if scroller is null then halt end if scroller.scrollTop < 20 then halt end end'
f' def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end'
f' add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me'
f' 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'
f' on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"'
f' role="status" aria-live="polite" aria-hidden="true">'
f'<div class="js-loading hidden flex justify-center py-2"><div class="animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"></div></div>'
f'<div class="js-neterr hidden text-center py-2 text-stone-400 text-sm">Retry…</div>'
f'</div>'
)
return "".join(parts)
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(
f'<div id="sentinel-{page_num}-d" class="h-4 opacity-0 pointer-events-none"'
f' hx-get="{next_url}" hx-trigger="intersect once delay:250ms" hx-swap="outerHTML"></div>'
)
elif pages:
parts.append('<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>')
else:
parts.append('<div class="col-span-full mt-8 text-center text-stone-500">No pages found.</div>')
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")
parts = ['<article class="border-b pb-6 last:border-b-0 relative">']
parts.append(
f'<a href="{href}" hx-get="{href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden">'
)
parts.append(f'<header class="mb-2 text-center"><h2 class="text-4xl font-bold text-stone-900">{escape(page.get("title", ""))}</h2>')
# Feature badges
features = page.get("features") or {}
if features:
parts.append('<div class="flex justify-center gap-2 mt-2">')
if features.get("calendar"):
parts.append('<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"><i class="fa fa-calendar mr-1"></i>Calendar</span>')
if features.get("market"):
parts.append('<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"><i class="fa fa-shopping-bag mr-1"></i>Market</span>')
parts.append('</div>')
pub = page.get("published_at")
if pub:
ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub)
parts.append(f'<p class="text-sm text-stone-500">Published: {ts}</p>')
parts.append('</header>')
fi = page.get("feature_image")
if fi:
parts.append(f'<div class="mb-4"><img src="{fi}" alt="" class="rounded-lg w-full object-cover"></div>')
excerpt = page.get("custom_excerpt") or page.get("excerpt", "")
if excerpt:
parts.append(f'<p class="text-stone-700 text-lg leading-relaxed text-center">{escape(excerpt)}</p>')
parts.append('</a></article>')
return "".join(parts)
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 = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>'
tile_svg = '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" /></svg>'
return (
f'<div class="hidden md:flex justify-end px-3 pt-3 gap-1">'
f'<a href="{list_href}" hx-get="{list_href}" hx-target="#main-panel" hx-select="{hx_select}"'
f' hx-swap="outerHTML" hx-push-url="true" class="p-1.5 rounded {list_cls}" title="List view"'
f' _="on click js localStorage.removeItem(\'blog_view\') end">{list_svg}</a>'
f'<a href="{tile_href}" hx-get="{tile_href}" hx-target="#main-panel" hx-select="{hx_select}"'
f' hx-swap="outerHTML" hx-push-url="true" class="p-1.5 rounded {tile_cls}" title="Tile view"'
f' _="on click js localStorage.setItem(\'blog_view\',\'tile\') end">{tile_svg}</a>'
f'</div>'
)
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 (
f'<div class="flex justify-center gap-1 px-3 pt-3">'
f'<a href="{posts_href}" hx-get="{posts_href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors {posts_cls}">Posts</a>'
f'<a href="{pages_href}" hx-get="{pages_href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors {pages_cls}">Pages</a>'
f'</div>'
)
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")
parts = [_content_type_tabs_html(ctx)]
if content_type == "pages":
parts.append('<div class="max-w-full px-3 py-3 space-y-3">')
parts.append(_page_cards_html(ctx))
parts.append('</div><div class="pb-8"></div>')
else:
parts.append(_view_toggle_html(ctx))
if view == "tile":
parts.append('<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">')
else:
parts.append('<div class="max-w-full px-3 py-3 space-y-3">')
parts.append(_blog_cards_html(ctx))
parts.append('</div><div class="pb-8"></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Desktop aside (filter sidebar)
# ---------------------------------------------------------------------------
def _blog_aside_html(ctx: dict) -> str:
"""Desktop aside with search, action buttons, and filters."""
parts = []
parts.append(search_desktop_html(ctx))
parts.append(_action_buttons_html(ctx))
parts.append(f'<div id="category-summary-desktop" hxx-swap-oob="outerHTML">')
parts.append(_tag_groups_filter_html(ctx))
parts.append(_authors_filter_html(ctx))
parts.append('</div>')
parts.append('<div id="filter-summary-desktop" hxx-swap-oob="outerHTML"></div>')
return "".join(parts)
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 sexp(
'(~mobile-filter :filter-summary-html fsh :action-buttons-html abh'
' :filter-details-html fdh)',
fsh=filter_content,
abh=action_buttons,
fdh=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 = ['<div class="flex flex-wrap gap-2 px-4 py-3">']
if has_admin:
new_href = call_url(ctx, "blog_url", "/new/")
parts.append(
f'<a href="{new_href}" hx-get="{new_href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"'
f' title="New Post"><i class="fa fa-plus mr-1"></i> New Post</a>'
)
new_page_href = call_url(ctx, "blog_url", "/new-page/")
parts.append(
f'<a href="{new_page_href}" hx-get="{new_page_href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"'
f' title="New Page"><i class="fa fa-plus mr-1"></i> New Page</a>'
)
if user and (draft_count or drafts):
if drafts:
off_href = f"{current_local_href}"
parts.append(
f'<a href="{off_href}" hx-get="{off_href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"'
f' title="Hide Drafts"><i class="fa fa-file-text-o mr-1"></i> Drafts'
f' <span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{draft_count}</span></a>'
)
else:
on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1"
parts.append(
f'<a href="{on_href}" hx-get="{on_href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"'
f' title="Show Drafts"><i class="fa fa-file-text-o mr-1"></i> Drafts'
f' <span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{draft_count}</span></a>'
)
parts.append('</div>')
return "".join(parts)
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")
parts = ['<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">'
'<ul class="divide-y flex flex-col gap-3">']
# "Any Topic" link
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"
parts.append(
f'<li><a class="px-3 py-1 rounded border {any_cls}"'
f' hx-get="?page=1" hx-target="#main-panel" hx-select="{hx_select}"'
f' hx-swap="outerHTML" hx-push-url="true">Any Topic</a></li>'
)
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 = f'<img src="{g_fi}" alt="{escape(g_name)}" class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0">'
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
icon = (
f'<div class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center'
f' border border-stone-300 flex-shrink-0" style="{style}">{escape(g_name[:1])}</div>'
)
parts.append(
f'<li><a class="flex items-center gap-2 px-3 py-1 rounded border {cls}"'
f' hx-get="?group={g_slug}&page=1" hx-target="#main-panel" hx-select="{hx_select}"'
f' hx-swap="outerHTML" hx-push-url="true">{icon}'
f'<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">{escape(g_name)}</span>'
f'<span class="flex-1"></span>'
f'<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">{g_count}</span>'
f'</a></li>'
)
parts.append('</ul></nav>')
return "".join(parts)
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")
parts = ['<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">'
'<ul class="divide-y flex flex-col gap-3">']
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"
parts.append(
f'<li><a class="px-3 py-1 rounded {any_cls}"'
f' hx-get="?page=1" hx-target="#main-panel" hx-select="{hx_select}"'
f' hx-swap="outerHTML" hx-push-url="true">Any author</a></li>'
)
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 = f'<img src="{a_img}" alt="{escape(a_name)}" class="h-5 w-5 rounded-full object-cover">'
parts.append(
f'<li><a class="flex items-center gap-2 px-3 py-1 rounded {cls}"'
f' hx-get="?author={a_slug}&page=1" hx-target="#main-panel" hx-select="{hx_select}"'
f' hx-swap="outerHTML" hx-push-url="true">{icon}'
f'<span class="text-stone-700">{escape(a_name)}</span>'
f'<span class="flex-1"></span>'
f'<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">{a_count}</span>'
f'</a></li>'
)
parts.append('</ul></nav>')
return "".join(parts)
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 f'<span class="text-sm text-stone-600">{escape(", ".join(names))}</span>'
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 f'<span class="text-sm text-stone-600">{escape(", ".join(names))}</span>'
# ---------------------------------------------------------------------------
# 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")
parts = ['<article class="relative">']
# Draft indicator
if post.get("status") == "draft":
parts.append('<div class="flex items-center justify-center gap-2 mb-3">')
parts.append('<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>')
if post.get("publish_requested"):
parts.append('<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800">Publish requested</span>')
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
edit_href = qurl("blog.post.admin.edit", slug=slug)
parts.append(
f'<a href="{edit_href}" hx-get="{edit_href}" hx-target="#main-panel"'
f' hx-select="{hx_select}" hx-swap="outerHTML" hx-push-url="true"'
f' class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors">'
f'<i class="fa fa-pencil mr-1"></i> Edit</a>'
)
parts.append('</div>')
# Blog post chrome (not for pages)
if not post.get("is_page"):
if user:
liked = post.get("is_liked", False)
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
parts.append(
f'<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">'
f'<button hx-post="{like_url}" hx-swap="outerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{ctx.get("csrf_token", "")}\"}}\''
f' class="cursor-pointer">{"❤️" if liked else "🤍"}</button></div>'
)
if post.get("custom_excerpt"):
parts.append(f'<div class="w-full text-center italic text-3xl p-2">{post["custom_excerpt"]}</div>')
# Desktop at_bar
parts.append(f'<div class="hidden md:block">{_at_bar_html(post, ctx)}</div>')
# Feature image
fi = post.get("feature_image")
if fi:
parts.append(f'<div class="mb-3 flex justify-center"><img src="{fi}" alt="" class="rounded-lg w-full md:w-3/4 object-cover"></div>')
# Post HTML content
html_content = post.get("html", "")
if html_content:
parts.append(f'<div class="blog-content p-2">{html_content}</div>')
parts.append('</article><div class="pb-8"></div>')
return "".join(parts)
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 "")
og_title = post.get("og_title") or base_title
tw_title = post.get("twitter_title") or base_title
is_article = not post.get("is_page")
parts = [f'<meta name="robots" content="{robots}">']
parts.append(f'<title>{escape(base_title)}</title>')
parts.append(f'<meta name="description" content="{escape(desc)}">')
if canonical:
parts.append(f'<link rel="canonical" href="{escape(canonical)}">')
parts.append(f'<meta property="og:type" content="{"article" if is_article else "website"}">')
parts.append(f'<meta property="og:title" content="{escape(og_title)}">')
parts.append(f'<meta property="og:description" content="{escape(desc)}">')
if canonical:
parts.append(f'<meta property="og:url" content="{escape(canonical)}">')
if image:
parts.append(f'<meta property="og:image" content="{escape(image)}">')
parts.append(f'<meta name="twitter:card" content="{"summary_large_image" if image else "summary"}">')
parts.append(f'<meta name="twitter:title" content="{escape(tw_title)}">')
parts.append(f'<meta name="twitter:description" content="{escape(desc)}">')
if image:
parts.append(f'<meta name="twitter:image" content="{escape(image)}">')
return "".join(parts)
# ---------------------------------------------------------------------------
# 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 f'<article class="relative"><div class="blog-content p-2">{html}</div></article>'
# ---------------------------------------------------------------------------
# Post admin - empty main panel
# ---------------------------------------------------------------------------
def _post_admin_main_panel_html(ctx: dict) -> str:
return '<div class="pb-8"></div>'
# ---------------------------------------------------------------------------
# Settings main panels
# ---------------------------------------------------------------------------
def _settings_main_panel_html(ctx: dict) -> str:
return '<div class="max-w-2xl mx-auto px-4 py-6"></div>'
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 (
f'<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">'
f'<div class="flex flex-col md:flex-row gap-3 items-start">'
f'<form hx-post="{clear_url}" hx-trigger="submit" hx-target="#cache-status" hx-swap="innerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button class="border rounded px-4 py-2 bg-stone-800 text-white text-sm" type="submit">Clear cache</button>'
f'</form>'
f'<div id="cache-status" class="py-2"></div>'
f'</div></div>'
)
# ---------------------------------------------------------------------------
# Snippets main panel
# ---------------------------------------------------------------------------
def _snippets_main_panel_html(ctx: dict) -> str:
return (
f'<div class="max-w-4xl mx-auto p-6">'
f'<div class="mb-6 flex justify-between items-center">'
f'<h1 class="text-3xl font-bold">Snippets</h1></div>'
f'<div id="snippets-list">{_snippets_list_html(ctx)}</div></div>'
)
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 (
'<div class="bg-white rounded-lg shadow">'
'<div class="p-8 text-center text-stone-400">'
'<i class="fa fa-puzzle-piece text-4xl mb-2"></i>'
'<p>No snippets yet. Create one from the blog editor.</p></div></div>'
)
badge_colours = {
"private": "bg-stone-200 text-stone-700",
"shared": "bg-blue-100 text-blue-700",
"admin": "bg-amber-100 text-amber-700",
}
parts = ['<div class="bg-white rounded-lg shadow"><div class="divide-y">']
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")
parts.append(
f'<div class="flex items-center gap-4 p-4 hover:bg-stone-50 transition">'
f'<div class="flex-1 min-w-0"><div class="font-medium truncate">{escape(s_name)}</div>'
f'<div class="text-xs text-stone-500">{owner}</div></div>'
f'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {badge_cls}">{s_vis}</span>'
)
if is_admin:
patch_url = qurl("snippets.patch_visibility", snippet_id=s_id)
parts.append(
f'<select name="visibility" hx-patch="{patch_url}" hx-target="#snippets-list" hx-swap="innerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{csrf}\"}}\' class="text-sm border border-stone-300 rounded px-2 py-1">'
)
for v in ["private", "shared", "admin"]:
sel = " selected" if s_vis == v else ""
parts.append(f'<option value="{v}"{sel}>{v}</option>')
parts.append('</select>')
if s_uid == user_id or is_admin:
del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
parts.append(
f'<button type="button" data-confirm data-confirm-title="Delete snippet?"'
f' data-confirm-text="Delete &ldquo;{escape(s_name)}&rdquo;?"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' hx-delete="{del_url}" hx-trigger="confirmed" hx-target="#snippets-list" hx-swap="innerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{csrf}\"}}\''
f' class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0">'
f'<i class="fa fa-trash"></i> Delete</button>'
)
parts.append('</div>')
parts.append('</div></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# 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")
return (
f'<div class="max-w-4xl mx-auto p-6">'
f'<div class="mb-6 flex justify-end items-center">'
f'<button type="button" hx-get="{new_url}" hx-target="#menu-item-form" hx-swap="innerHTML"'
f' class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">'
f'<i class="fa fa-plus"></i> Add Menu Item</button></div>'
f'<div id="menu-item-form" class="mb-6"></div>'
f'<div id="menu-items-list">{_menu_items_list_html(ctx)}</div></div>'
)
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 (
'<div class="bg-white rounded-lg shadow">'
'<div class="p-8 text-center text-stone-400">'
'<i class="fa fa-inbox text-4xl mb-2"></i>'
'<p>No menu items yet. Add one to get started!</p></div></div>'
)
parts = ['<div class="bg-white rounded-lg shadow"><div class="divide-y">']
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 = (f'<img src="{fi}" alt="{escape(label)}" class="w-12 h-12 rounded-full object-cover flex-shrink-0" />'
if fi else '<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>')
parts.append(
f'<div class="flex items-center gap-4 p-4 hover:bg-stone-50 transition">'
f'<div class="text-stone-400 cursor-move"><i class="fa fa-grip-vertical"></i></div>'
f'{img}'
f'<div class="flex-1 min-w-0"><div class="font-medium truncate">{escape(label)}</div>'
f'<div class="text-xs text-stone-500 truncate">{escape(slug)}</div></div>'
f'<div class="text-sm text-stone-500">Order: {sort}</div>'
f'<div class="flex gap-2 flex-shrink-0">'
f'<button type="button" hx-get="{edit_url}" hx-target="#menu-item-form" hx-swap="innerHTML"'
f' class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"><i class="fa fa-edit"></i> Edit</button>'
f'<button type="button" data-confirm data-confirm-title="Delete menu item?"'
f' data-confirm-text="Remove {escape(label)} from the menu?"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, delete"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' hx-delete="{del_url}" hx-trigger="confirmed" hx-target="#menu-items-list" hx-swap="innerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{csrf}\"}}\''
f' class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800">'
f'<i class="fa fa-trash"></i> Delete</button></div></div>'
)
parts.append('</div></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# 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", "")
parts = ['<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">']
# Create form
create_url = qurl("blog.tag_groups_admin.create")
parts.append(
f'<form method="post" action="{create_url}" class="border rounded p-4 bg-white space-y-3">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<h3 class="text-sm font-semibold text-stone-700">New Group</h3>'
f'<div class="flex flex-col sm:flex-row gap-3">'
f'<input type="text" name="name" placeholder="Group name" required class="flex-1 border rounded px-3 py-2 text-sm">'
f'<input type="text" name="colour" placeholder="#colour" class="w-28 border rounded px-3 py-2 text-sm">'
f'<input type="number" name="sort_order" placeholder="Order" value="0" class="w-20 border rounded px-3 py-2 text-sm">'
f'</div>'
f'<input type="text" name="feature_image" placeholder="Image URL (optional)" class="w-full border rounded px-3 py-2 text-sm">'
f'<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">Create</button>'
f'</form>'
)
# Groups list
if groups:
parts.append('<ul class="space-y-2">')
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 = f'<img src="{g_fi}" alt="{escape(g_name)}" class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
icon = (
f'<div class="h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"'
f' style="{style}">{escape(g_name[:1])}</div>'
)
parts.append(
f'<li class="border rounded p-3 bg-white flex items-center gap-3">{icon}'
f'<div class="flex-1"><a href="{edit_href}" class="font-medium text-stone-800 hover:underline">{escape(g_name)}</a>'
f'<span class="text-xs text-stone-500 ml-2">{escape(g_slug)}</span></div>'
f'<span class="text-xs text-stone-500">order: {g_sort}</span></li>'
)
parts.append('</ul>')
else:
parts.append('<p class="text-stone-500 text-sm">No tag groups yet.</p>')
# Unassigned tags
if unassigned_tags:
parts.append(f'<div class="border-t pt-4"><h3 class="text-sm font-semibold text-stone-700 mb-2">Unassigned Tags ({len(unassigned_tags)})</h3>')
parts.append('<div class="flex flex-wrap gap-2">')
for tag in unassigned_tags:
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
parts.append(
f'<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded">'
f'{escape(t_name)}</span>'
)
parts.append('</div></div>')
parts.append('</div>')
return "".join(parts)
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)
parts = [f'<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">']
# Edit form
parts.append(
f'<form method="post" action="{save_url}" class="border rounded p-4 bg-white space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<div class="space-y-3">'
f'<div><label class="block text-xs font-medium text-stone-600 mb-1">Name</label>'
f'<input type="text" name="name" value="{escape(g_name)}" required class="w-full border rounded px-3 py-2 text-sm"></div>'
f'<div class="flex gap-3"><div class="flex-1"><label class="block text-xs font-medium text-stone-600 mb-1">Colour</label>'
f'<input type="text" name="colour" value="{escape(g_colour or "")}" placeholder="#hex" class="w-full border rounded px-3 py-2 text-sm"></div>'
f'<div class="w-24"><label class="block text-xs font-medium text-stone-600 mb-1">Order</label>'
f'<input type="number" name="sort_order" value="{g_sort}" class="w-full border rounded px-3 py-2 text-sm"></div></div>'
f'<div><label class="block text-xs font-medium text-stone-600 mb-1">Feature Image URL</label>'
f'<input type="text" name="feature_image" value="{escape(g_fi or "")}" placeholder="https://..." class="w-full border rounded px-3 py-2 text-sm"></div>'
f'</div>'
)
# Tag checkboxes
parts.append(
'<div><label class="block text-xs font-medium text-stone-600 mb-2">Assign Tags</label>'
'<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2">'
)
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 = " checked" if t_id in assigned_tag_ids else ""
img = f'<img src="{t_fi}" alt="" class="h-4 w-4 rounded-full object-cover">' if t_fi else ""
parts.append(
f'<label class="flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer">'
f'<input type="checkbox" name="tag_ids" value="{t_id}"{checked} class="rounded border-stone-300">'
f'{img}<span>{escape(t_name)}</span></label>'
)
parts.append('</div></div>')
parts.append(
'<div class="flex gap-3"><button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">Save</button></div>'
'</form>'
)
# Delete form
parts.append(
f'<form method="post" action="{del_url}" class="border-t pt-4"'
f' onsubmit="return confirm(\'Delete this tag group? Tags will not be deleted.\')">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="border rounded px-4 py-2 bg-red-600 text-white text-sm">Delete Group</button></form>'
)
parts.append('</div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# 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(
'<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300'
' bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">'
f'<strong>Save failed:</strong> {esc(save_error)}</div>'
)
# Form
parts.append(
'<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<input type="hidden" id="lexical-json-input" name="lexical" value="">'
'<input type="hidden" id="feature-image-input" name="feature_image" value="">'
'<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">'
)
# Feature image section
parts.append(
'<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">'
# Empty state
'<div id="feature-image-empty">'
'<button type="button" id="feature-image-add-btn"'
' class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"'
'>+ Add feature image</button></div>'
# Filled state
'<div id="feature-image-filled" class="relative hidden">'
'<img id="feature-image-preview" src="" alt=""'
' class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer">'
'<button type="button" id="feature-image-delete-btn"'
' class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white'
' flex items-center justify-center opacity-0 group-hover:opacity-100'
' transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"'
' title="Remove feature image">'
'<i class="fa-solid fa-trash-can"></i></button>'
'<input type="text" id="feature-image-caption" value=""'
' placeholder="Add a caption..."'
' class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none'
' outline-none placeholder:text-stone-300 focus:text-stone-700">'
'</div>'
# Upload spinner
'<div id="feature-image-uploading"'
' class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">'
'<i class="fa-solid fa-spinner fa-spin"></i> Uploading...</div>'
# Hidden file input
'<input type="file" id="feature-image-file"'
' accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml" class="hidden">'
'</div>'
)
# Title
parts.append(
f'<input type="text" name="title" value="" placeholder="{title_placeholder}"'
' class="w-full text-[36px] font-bold bg-transparent border-none outline-none'
' placeholder:text-stone-300 mb-[8px] leading-tight">'
)
# Excerpt
parts.append(
'<textarea name="custom_excerpt" rows="1" placeholder="Add an excerpt..."'
' class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none'
' placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"></textarea>'
)
# Editor mount point
parts.append('<div id="lexical-editor" class="relative w-full bg-transparent"></div>')
# Status + Save footer
parts.append(
'<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">'
'<select name="status"'
' class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600">'
'<option value="draft" selected>Draft</option>'
'<option value="published">Published</option></select>'
'<button type="submit"'
' class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]'
f' hover:bg-stone-800 transition-colors cursor-pointer">{create_label}</button>'
'</div></form>'
)
# Editor CSS + inline styles
parts.append(
f'<link rel="stylesheet" href="{editor_css}">'
'<style>'
'#lexical-editor { display: flow-root; }'
'#lexical-editor [data-kg-card="html"] * { float: none !important; }'
'#lexical-editor [data-kg-card="html"] table { width: 100% !important; }'
'</style>'
)
# Editor JS + init script
# NOTE: JavaScript string literals use single quotes; Python f-string injects URLs.
parts.append(
f'<script src="{editor_js}"></script>'
"<script>\n"
"(function() {\n"
" function applyEditorFontSize() {\n"
" document.documentElement.style.fontSize = '62.5%';\n"
" document.body.style.fontSize = '1.6rem';\n"
" }\n"
" function restoreDefaultFontSize() {\n"
" document.documentElement.style.fontSize = '';\n"
" document.body.style.fontSize = '';\n"
" }\n"
" applyEditorFontSize();\n"
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {\n"
" if (e.detail.target && e.detail.target.id === 'main-panel') {\n"
" restoreDefaultFontSize();\n"
" document.body.removeEventListener('htmx:beforeSwap', cleanup);\n"
" }\n"
" });\n"
"\n"
" function init() {\n"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
f" var uploadUrl = '{upload_image_url}';\n"
" var uploadUrls = {\n"
" image: uploadUrl,\n"
f" media: '{upload_media_url}',\n"
f" file: '{upload_file_url}',\n"
" };\n"
"\n"
" var fileInput = document.getElementById('feature-image-file');\n"
" var addBtn = document.getElementById('feature-image-add-btn');\n"
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
" var preview = document.getElementById('feature-image-preview');\n"
" var emptyState = document.getElementById('feature-image-empty');\n"
" var filledState = document.getElementById('feature-image-filled');\n"
" var hiddenUrl = document.getElementById('feature-image-input');\n"
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
" var captionInput = document.getElementById('feature-image-caption');\n"
" var uploading = document.getElementById('feature-image-uploading');\n"
"\n"
" function showFilled(url) {\n"
" preview.src = url;\n"
" hiddenUrl.value = url;\n"
" emptyState.classList.add('hidden');\n"
" filledState.classList.remove('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function showEmpty() {\n"
" preview.src = '';\n"
" hiddenUrl.value = '';\n"
" hiddenCaption.value = '';\n"
" captionInput.value = '';\n"
" emptyState.classList.remove('hidden');\n"
" filledState.classList.add('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function uploadFile(file) {\n"
" emptyState.classList.add('hidden');\n"
" uploading.classList.remove('hidden');\n"
" var fd = new FormData();\n"
" fd.append('file', file);\n"
" fetch(uploadUrl, {\n"
" method: 'POST',\n"
" body: fd,\n"
" headers: { 'X-CSRFToken': csrfToken },\n"
" })\n"
" .then(function(r) {\n"
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
" return r.json();\n"
" })\n"
" .then(function(data) {\n"
" var url = data.images && data.images[0] && data.images[0].url;\n"
" if (url) showFilled(url);\n"
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
" })\n"
" .catch(function(e) {\n"
" showEmpty();\n"
" alert(e.message);\n"
" });\n"
" }\n"
"\n"
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
" preview.addEventListener('click', function() { fileInput.click(); });\n"
" deleteBtn.addEventListener('click', function(e) {\n"
" e.stopPropagation();\n"
" showEmpty();\n"
" });\n"
" fileInput.addEventListener('change', function() {\n"
" if (fileInput.files && fileInput.files[0]) {\n"
" uploadFile(fileInput.files[0]);\n"
" fileInput.value = '';\n"
" }\n"
" });\n"
" captionInput.addEventListener('input', function() {\n"
" hiddenCaption.value = captionInput.value;\n"
" });\n"
"\n"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
" function autoResize() {\n"
" excerpt.style.height = 'auto';\n"
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
" }\n"
" excerpt.addEventListener('input', autoResize);\n"
" autoResize();\n"
"\n"
" window.mountEditor('lexical-editor', {\n"
" initialJson: null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
f" unsplashApiKey: '{unsplash_key}',\n"
f" snippetsUrl: '{snippets_url}',\n"
" });\n"
"\n"
" document.addEventListener('keydown', function(e) {\n"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
" e.preventDefault();\n"
" document.getElementById('post-new-form').requestSubmit();\n"
" }\n"
" });\n"
" }\n"
"\n"
" if (typeof window.mountEditor === 'function') {\n"
" init();\n"
" } else {\n"
" var _t = setInterval(function() {\n"
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
" }, 50);\n"
" }\n"
"})();\n"
"</script>"
)
return "".join(parts)
# ---- New post/page ----
async def render_new_post_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
blog_hdr = _blog_header_html(ctx)
header_rows = root_hdr + blog_hdr
content = 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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
data_hdr = _post_sub_admin_header_html(
"post_data-row", "post_data-header-child",
qurl("blog.post.admin.data", slug=slug),
"database", "data", ctx,
)
header_rows = root_hdr + post_hdr + admin_hdr + data_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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
data_hdr = _post_sub_admin_header_html(
"post_data-row", "post_data-header-child",
qurl("blog.post.admin.data", slug=slug),
"database", "data", ctx,
)
data_oob = _oob_header_html("post-admin-header-child", "post_data-header-child",
data_hdr)
content = ctx.get("data_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob + data_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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
entries_hdr = _post_sub_admin_header_html(
"post_entries-row", "post_entries-header-child",
qurl("blog.post.admin.entries", slug=slug),
"clock", "entries", ctx,
)
header_rows = root_hdr + post_hdr + admin_hdr + entries_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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
entries_hdr = _post_sub_admin_header_html(
"post_entries-row", "post_entries-header-child",
qurl("blog.post.admin.entries", slug=slug),
"clock", "entries", ctx,
)
entries_oob = _oob_header_html("post-admin-header-child", "post_entries-header-child",
entries_hdr)
content = ctx.get("entries_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob + entries_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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
edit_hdr = _post_sub_admin_header_html(
"post_edit-row", "post_edit-header-child",
qurl("blog.post.admin.edit", slug=slug),
"pen-to-square", "edit", ctx,
)
header_rows = root_hdr + post_hdr + admin_hdr + edit_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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
edit_hdr = _post_sub_admin_header_html(
"post_edit-row", "post_edit-header-child",
qurl("blog.post.admin.edit", slug=slug),
"pen-to-square", "edit", ctx,
)
edit_oob = _oob_header_html("post-admin-header-child", "post_edit-header-child",
edit_hdr)
content = ctx.get("edit_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob + edit_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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
settings_hdr = _post_sub_admin_header_html(
"post_settings-row", "post_settings-header-child",
qurl("blog.post.admin.settings", slug=slug),
"cog", "settings", ctx,
)
header_rows = root_hdr + post_hdr + admin_hdr + settings_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)
from quart import url_for as qurl
slug = (ctx.get("post") or {}).get("slug", "")
settings_hdr = _post_sub_admin_header_html(
"post_settings-row", "post_settings-header-child",
qurl("blog.post.admin.settings", slug=slug),
"cog", "settings", ctx,
)
settings_oob = _oob_header_html("post-admin-header-child", "post_settings-header-child",
settings_hdr)
content = ctx.get("settings_html", "")
return oob_page(ctx, oobs_html=admin_hdr_oob + settings_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 '<div id="menu-items-nav-wrapper" hx-swap-oob="outerHTML"></div>'
# Resolve URL helpers from context or fall back to template globals
if ctx is None:
ctx = {}
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
# nav_button style (matches shared/infrastructure/jinja_setup.py)
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_button_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-3"
)
container_id = "menu-items-container"
arrow_cls = f"scrolling-menu-arrow-{container_id}"
parts = [
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' id="menu-items-nav-wrapper" hx-swap-oob="outerHTML">',
# Left arrow
f'<button class="{arrow_cls} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
f' aria-label="Scroll left"'
f' _="on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200">'
f'<i class="fa fa-chevron-left"></i></button>',
# Scrollable container
f'<div id="{container_id}"'
f' class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
f' style="scroll-behavior: smooth;"'
f' _="on load or scroll'
f' if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth'
f' remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}'
f' else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end">',
'<div class="flex flex-col sm:flex-row gap-1">',
]
blog_url_fn = ctx.get("blog_url")
cart_url_fn = ctx.get("cart_url")
app_name = ctx.get("app_name", "")
for item in menu_items:
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
# Determine href — cart slug maps to cart_url, others to blog_url
if item_slug == "cart" and cart_url_fn:
href = cart_url_fn("/")
elif blog_url_fn:
href = blog_url_fn(f"/{item_slug}/")
else:
href = f"/{item_slug}/"
selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false"
if fi:
img = f'<img src="{fi}" alt="{escape(label)}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
# Items that are not special app slugs get htmx attributes
htmx_attrs = ""
if item_slug != "cart":
htmx_attrs = (
f' hx-get="/{item_slug}/" hx-target="#main-panel"'
f' hx-swap="outerHTML" hx-push-url="true"'
)
parts.append(
f'<div><a href="{href}"{htmx_attrs}'
f' aria-selected="{selected}" class="{nav_button_cls}">'
f'{img}<span>{escape(label)}</span></a></div>'
)
parts.append('</div></div>') # close flex-col + scroll container
# scrollbar-hide style
parts.append(
'<style>.scrollbar-hide::-webkit-scrollbar { display: none; }'
' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }</style>'
)
# Right arrow
parts.append(
f'<button class="{arrow_cls} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
f' aria-label="Scroll right"'
f' _="on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200">'
f'<i class="fa fa-chevron-right"></i></button>'
)
parts.append('</div>') # close wrapper
return "".join(parts)
# ---- Features panel ----
def render_features_panel(features: dict, post: dict,
sumup_configured: bool,
sumup_merchant_code: str,
sumup_checkout_prefix: str) -> str:
"""Render the features panel fragment for HTMX PUT responses."""
from shared.utils import host_url
from quart import url_for as qurl
slug = post.get("slug", "")
features_url = host_url(qurl("blog.post.admin.update_features", slug=slug))
sumup_url = host_url(qurl("blog.post.admin.update_sumup", slug=slug))
cal_checked = " checked" if features.get("calendar") else ""
mkt_checked = " checked" if features.get("market") else ""
parts = [
'<div id="features-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">',
'<h3 class="text-lg font-semibold text-stone-800">Page Features</h3>',
f'<form hx-put="{features_url}" hx-target="#features-panel" hx-swap="outerHTML"'
f' hx-headers=\'{{\"Content-Type\": \"application/json\"}}\' hx-ext="json-enc" class="space-y-3">',
# Calendar checkbox
'<label class="flex items-center gap-3 cursor-pointer">'
f'<input type="checkbox" name="calendar" value="true"{cal_checked}'
' class="h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"'
' _="on change trigger submit on closest &lt;form/&gt;">'
'<span class="text-sm text-stone-700">'
'<i class="fa fa-calendar text-blue-600 mr-1"></i>'
' Calendar \u2014 enable event booking on this page</span></label>',
# Market checkbox
'<label class="flex items-center gap-3 cursor-pointer">'
f'<input type="checkbox" name="market" value="true"{mkt_checked}'
' class="h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"'
' _="on change trigger submit on closest &lt;form/&gt;">'
'<span class="text-sm text-stone-700">'
'<i class="fa fa-shopping-bag text-green-600 mr-1"></i>'
' Market \u2014 enable product catalog on this page</span></label>',
'</form>',
]
# SumUp section — shown when calendar or market is enabled
if features.get("calendar") or features.get("market"):
placeholder = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" if sumup_configured else "sup_sk_..."
connected = (
'<span class="ml-2 text-xs text-green-600">'
'<i class="fa fa-check-circle"></i> Connected</span>'
) if sumup_configured else ""
key_hint = (
'<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>'
) if sumup_configured else ""
parts.append(
'<div class="mt-4 pt-4 border-t border-stone-100">'
'<h4 class="text-sm font-medium text-stone-700">'
'<i class="fa fa-credit-card text-purple-600 mr-1"></i> SumUp Payment</h4>'
'<p class="text-xs text-stone-400 mt-1 mb-3">'
'Configure per-page SumUp credentials. Leave blank to use the global merchant account.</p>'
f'<form hx-put="{sumup_url}" hx-target="#features-panel" hx-swap="outerHTML" class="space-y-3">'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>'
f'<input type="text" name="merchant_code" value="{escape(sumup_merchant_code)}"'
' placeholder="e.g. ME4J6100"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>'
f'<input type="password" name="api_key" value="" placeholder="{placeholder}"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500">'
f'{key_hint}</div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>'
f'<input type="text" name="checkout_prefix" value="{escape(sumup_checkout_prefix)}"'
' placeholder="e.g. ROSE-"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<button type="submit"'
' class="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500">'
'Save SumUp Settings</button>'
f'{connected}</form></div>'
)
parts.append('</div>')
return "".join(parts)
# ---- Markets panel ----
def render_markets_panel(markets, post: dict) -> str:
"""Render the markets panel fragment for HTMX responses."""
from shared.utils import host_url
from quart import url_for as qurl
slug = post.get("slug", "")
create_url = host_url(qurl("blog.post.admin.create_market", slug=slug))
parts = ['<div id="markets-panel">',
'<h3 class="text-lg font-semibold mb-3">Markets</h3>']
if markets:
parts.append('<ul class="space-y-2 mb-4">')
for m in markets:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
del_url = host_url(qurl("blog.post.admin.delete_market", slug=slug, market_slug=m_slug))
parts.append(
f'<li class="flex items-center justify-between p-3 bg-stone-50 rounded">'
f'<div><span class="font-medium">{escape(m_name)}</span>'
f'<span class="text-stone-400 text-sm ml-2">/{escape(m_slug)}/</span></div>'
f'<button hx-delete="{del_url}" hx-target="#markets-panel" hx-swap="outerHTML"'
f' hx-confirm="Delete market \'{escape(m_name)}\'?"'
f' class="text-red-600 hover:text-red-800 text-sm">Delete</button></li>'
)
parts.append('</ul>')
else:
parts.append('<p class="text-stone-500 mb-4 text-sm">No markets yet.</p>')
parts.append(
f'<form hx-post="{create_url}" hx-target="#markets-panel" hx-swap="outerHTML" class="flex gap-2">'
'<input type="text" name="name" placeholder="Market name" required'
' class="flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm" />'
'<button type="submit"'
' class="bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700">Create</button>'
'</form></div>'
)
return "".join(parts)
# ---- Associated entries ----
def render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
"""Render the associated entries panel for HTMX POST responses."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for as qurl
from shared.utils import host_url
csrf = generate_csrf_token()
parts = ['<div id="associated-entries-list" class="border rounded-lg p-4 bg-white">',
'<h3 class="text-lg font-semibold mb-4">Associated Entries</h3>']
has_entries = False
entry_parts: list[str] = []
for calendar in all_calendars:
entries = getattr(calendar, "entries", []) or []
cal_name = getattr(calendar, "name", "")
cal_post = getattr(calendar, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
for entry in entries:
e_id = getattr(entry, "id", None)
if e_id not in associated_entry_ids:
continue
if getattr(entry, "deleted_at", None) is not None:
continue
has_entries = True
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))
if cal_fi:
img = f'<img src="{cal_fi}" alt="{escape(cal_title)}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
entry_parts.append(
f'<button type="button"'
f' class="w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"'
f' data-confirm data-confirm-title="Remove entry?"'
f' data-confirm-text="This will remove {escape(e_name)} from this post"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' hx-post="{toggle_url}" hx-trigger="confirmed"'
f' hx-target="#associated-entries-list" hx-swap="outerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{csrf}\"}}\''
f' _="on htmx:afterRequest trigger entryToggled on body">'
f'<div class="flex items-center justify-between gap-3">'
f'{img}'
f'<div class="flex-1">'
f'<div class="font-medium text-sm">{escape(e_name)}</div>'
f'<div class="text-xs text-stone-600 mt-1">{escape(cal_name)} \u2022 {date_str}</div>'
f'</div>'
f'<i class="fa fa-times-circle text-green-600 text-lg flex-shrink-0"></i>'
f'</div></button>'
)
if has_entries:
parts.append('<div class="space-y-1">')
parts.extend(entry_parts)
parts.append('</div>')
else:
parts.append(
'<div class="text-sm text-stone-400">No entries associated yet.'
' Browse calendars below to add entries.</div>'
)
parts.append('</div>')
return "".join(parts)
# ---- Nav entries OOB ----
def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict | None = None) -> str:
"""Render the OOB nav entries swap.
Produces the ``entries-calendars-nav-wrapper`` OOB element with links
to associated entries and calendars.
"""
if ctx is None:
ctx = {}
entries_list = []
if associated_entries and hasattr(associated_entries, "entries"):
entries_list = associated_entries.entries or []
has_items = bool(entries_list or calendars)
if not has_items:
return '<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>'
events_url_fn = ctx.get("events_url")
# nav_button_less_pad style
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-2"
)
post_slug = post.get("slug", "")
parts = [
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' id="entries-calendars-nav-wrapper" hx-swap-oob="true">',
# Left arrow
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' aria-label="Scroll left"'
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">'
'<i class="fa fa-chevron-left"></i></button>',
# Container
'<div id="associated-items-container"'
' class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
' style="scroll-behavior: smooth;"'
' _="on load or scroll'
' if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth'
' remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow'
' else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end">',
'<div class="flex flex-col sm:flex-row gap-1">',
]
# Entry links
for entry in entries_list:
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
cal_slug = getattr(entry, "calendar_slug", "")
if e_start:
entry_path = (
f"/{post_slug}/calendars/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
)
date_str = e_start.strftime("%b %d, %Y at %H:%M")
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
entry_path = f"/{post_slug}/calendars/{cal_slug}/"
date_str = ""
href = events_url_fn(entry_path) if events_url_fn else entry_path
parts.append(
f'<a href="{href}" class="{nav_cls}">'
f'<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>'
f'<div class="flex-1 min-w-0">'
f'<div class="font-medium truncate">{escape(e_name)}</div>'
f'<div class="text-xs text-stone-600 truncate">{date_str}</div>'
f'</div></a>'
)
# Calendar links
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
cal_path = f"/{post_slug}/calendars/{cal_slug}/"
href = events_url_fn(cal_path) if events_url_fn else cal_path
parts.append(
f'<a href="{href}" class="{nav_cls}">'
f'<i class="fa fa-calendar" aria-hidden="true"></i>'
f'<div>{escape(cal_name)}</div></a>'
)
parts.append('</div></div>') # close flex + container
# Scrollbar style
parts.append(
'<style>.scrollbar-hide::-webkit-scrollbar { display: none; }'
' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }</style>'
)
# Right arrow
parts.append(
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' aria-label="Scroll right"'
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">'
'<i class="fa fa-chevron-right"></i></button>'
)
parts.append('</div>')
return "".join(parts)