diff --git a/blog/sexp/sexp_components.py b/blog/sexp/sexp_components.py
index 8bbaa58..de0ce56 100644
--- a/blog/sexp/sexp_components.py
+++ b/blog/sexp/sexp_components.py
@@ -25,10 +25,11 @@ from shared.sexp.helpers import (
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'
No pages found.
')
+ parts.append(sexp('(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found.")'))
return "".join(parts)
@@ -569,41 +597,40 @@ def _page_card_html(page: dict, ctx: dict) -> str:
href = call_url(ctx, "blog_url", f"/{slug}/")
hx_select = ctx.get("hx_select_search", "#main-panel")
- parts = ['')
- else:
- parts.append('
')
- parts.append(_blog_cards_html(ctx))
- parts.append('
')
-
- return "".join(parts)
+ toggle = _view_toggle_html(ctx)
+ grid_cls = "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" if view == "tile" else "max-w-full px-3 py-3 space-y-3"
+ cards = _blog_cards_html(ctx)
+ return sexp(
+ '(<> (raw! tabs) (raw! toggle) (div :class gc (raw! cards)) (div :class "pb-8"))',
+ tabs=tabs, toggle=toggle, gc=grid_cls, cards=cards,
+ )
# ---------------------------------------------------------------------------
@@ -687,15 +725,17 @@ def _blog_main_panel_html(ctx: dict) -> str:
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'
')
- parts.append(_tag_groups_filter_html(ctx))
- parts.append(_authors_filter_html(ctx))
- parts.append('
')
- parts.append('
')
- return "".join(parts)
+ sd = search_desktop_html(ctx)
+ ab = _action_buttons_html(ctx)
+ tgf = _tag_groups_filter_html(ctx)
+ af = _authors_filter_html(ctx)
+ return sexp(
+ '(<> (raw! sd) (raw! ab)'
+ ' (div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"'
+ ' (raw! tgf) (raw! af))'
+ ' (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML"))',
+ sd=sd, ab=ab, tgf=tgf, af=af,
+ )
def _blog_filter_html(ctx: dict) -> str:
@@ -736,46 +776,50 @@ def _action_buttons_html(ctx: dict) -> str:
draft_count = ctx.get("draft_count", 0)
current_local_href = ctx.get("current_local_href", "/index")
- parts = ['
']
+ parts = []
if has_admin:
new_href = call_url(ctx, "blog_url", "/new/")
- parts.append(
- f'
New Post'
- )
+ parts.append(sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"'
+ ' :title "New Post" (i :class "fa fa-plus mr-1") " New Post")',
+ h=new_href, hs=hx_select,
+ ))
new_page_href = call_url(ctx, "blog_url", "/new-page/")
- parts.append(
- f'
New Page'
- )
+ parts.append(sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"'
+ ' :title "New Page" (i :class "fa fa-plus mr-1") " New Page")',
+ h=new_page_href, hs=hx_select,
+ ))
if user and (draft_count or drafts):
if drafts:
off_href = f"{current_local_href}"
- parts.append(
- f'
Drafts'
- f' {draft_count} '
- )
+ parts.append(sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"'
+ ' :title "Hide Drafts" (i :class "fa fa-file-text-o mr-1") " Drafts "'
+ ' (span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" dc))',
+ h=off_href, hs=hx_select, dc=str(draft_count),
+ ))
else:
on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1"
- parts.append(
- f'
Drafts'
- f' {draft_count} '
- )
+ parts.append(sexp(
+ '(a :href h :hx-get h :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"'
+ ' :title "Show Drafts" (i :class "fa fa-file-text-o mr-1") " Drafts "'
+ ' (span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" dc))',
+ h=on_href, hs=hx_select, dc=str(draft_count),
+ ))
- parts.append('
')
- return "".join(parts)
+ inner = "".join(parts)
+ return sexp('(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner))', inner=inner)
def _tag_groups_filter_html(ctx: dict) -> str:
@@ -785,17 +829,15 @@ def _tag_groups_filter_html(ctx: dict) -> str:
selected_tags = ctx.get("selected_tags") or ()
hx_select = ctx.get("hx_select_search", "#main-panel")
- parts = ['
'
- '']
-
- # "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'Any Topic '
- )
+
+ li_parts = [sexp(
+ '(li (a :class (str "px-3 py-1 rounded border " ac)'
+ ' :hx-get "?page=1" :hx-target "#main-panel" :hx-select hs'
+ ' :hx-swap "outerHTML" :hx-push-url "true" "Any Topic"))',
+ ac=any_cls, hs=hx_select,
+ )]
for group in tag_groups:
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
@@ -811,26 +853,36 @@ def _tag_groups_filter_html(ctx: dict) -> str:
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' '
+ icon = sexp(
+ '(img :src fi :alt n :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0")',
+ fi=g_fi, n=g_name,
+ )
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
- icon = (
- f'{escape(g_name[:1])}
'
+ icon = sexp(
+ '(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center'
+ ' border border-stone-300 flex-shrink-0" :style st i)',
+ st=style, i=g_name[:1],
)
- parts.append(
- f'{icon}'
- f'{escape(g_name)} '
- f' '
- f'{g_count} '
- f' '
- )
+ li_parts.append(sexp(
+ '(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " c)'
+ ' :hx-get hg :hx-target "#main-panel" :hx-select hs'
+ ' :hx-swap "outerHTML" :hx-push-url "true"'
+ ' (raw! ic)'
+ ' (span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" n)'
+ ' (span :class "flex-1")'
+ ' (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" gc)))',
+ c=cls, hg=f"?group={g_slug}&page=1", hs=hx_select,
+ ic=icon, n=g_name, gc=str(g_count),
+ ))
- parts.append(' ')
- return "".join(parts)
+ items = "".join(li_parts)
+ return sexp(
+ '(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" (raw! items)))',
+ items=items,
+ )
def _authors_filter_html(ctx: dict) -> str:
@@ -839,16 +891,15 @@ def _authors_filter_html(ctx: dict) -> str:
selected_authors = ctx.get("selected_authors") or ()
hx_select = ctx.get("hx_select_search", "#main-panel")
- parts = ['
'
- '']
-
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'Any author '
- )
+
+ li_parts = [sexp(
+ '(li (a :class (str "px-3 py-1 rounded " ac)'
+ ' :hx-get "?page=1" :hx-target "#main-panel" :hx-select hs'
+ ' :hx-swap "outerHTML" :hx-push-url "true" "Any author"))',
+ ac=any_cls, hs=hx_select,
+ )]
for author in authors:
a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "")
@@ -861,20 +912,29 @@ def _authors_filter_html(ctx: dict) -> str:
icon = ""
if a_img:
- icon = f' '
+ icon = sexp(
+ '(img :src ai :alt n :class "h-5 w-5 rounded-full object-cover")',
+ ai=a_img, n=a_name,
+ )
- parts.append(
- f'{icon}'
- f'{escape(a_name)} '
- f' '
- f'{a_count} '
- f' '
- )
+ li_parts.append(sexp(
+ '(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " c)'
+ ' :hx-get hg :hx-target "#main-panel" :hx-select hs'
+ ' :hx-swap "outerHTML" :hx-push-url "true"'
+ ' (raw! ic)'
+ ' (span :class "text-stone-700" n)'
+ ' (span :class "flex-1")'
+ ' (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" ac)))',
+ c=cls, hg=f"?author={a_slug}&page=1", hs=hx_select,
+ ic=icon, n=a_name, ac=str(a_count),
+ ))
- parts.append(' ')
- return "".join(parts)
+ items = "".join(li_parts)
+ return sexp(
+ '(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" (raw! items)))',
+ items=items,
+ )
def _tag_groups_filter_summary_html(ctx: dict) -> str:
@@ -891,7 +951,7 @@ def _tag_groups_filter_summary_html(ctx: dict) -> str:
names.append(g_name)
if not names:
return ""
- return f'
{escape(", ".join(names))} '
+ return sexp('(span :class "text-sm text-stone-600" t)', t=", ".join(names))
def _authors_filter_summary_html(ctx: dict) -> str:
@@ -908,7 +968,7 @@ def _authors_filter_summary_html(ctx: dict) -> str:
names.append(a_name)
if not names:
return ""
- return f'
{escape(", ".join(names))} '
+ return sexp('(span :class "text-sm text-stone-600" t)', t=", ".join(names))
# ---------------------------------------------------------------------------
@@ -926,54 +986,69 @@ def _post_main_panel_html(ctx: dict) -> str:
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
hx_select = ctx.get("hx_select_search", "#main-panel")
- parts = ['
']
-
# Draft indicator
+ draft_html = ""
if post.get("status") == "draft":
- parts.append('')
- parts.append('
Draft ')
- if post.get("publish_requested"):
- parts.append('
Publish requested ')
+ edit_html = ""
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
edit_href = qurl("blog.post.admin.edit", slug=slug)
- parts.append(
- f'
'
- f' Edit '
+ edit_html = sexp(
+ '(a :href eh :hx-get eh :hx-target "#main-panel"'
+ ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"'
+ ' :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"'
+ ' (i :class "fa fa-pencil mr-1") " Edit")',
+ eh=edit_href, hs=hx_select,
)
- parts.append('
')
+ draft_html = sexp(
+ '(div :class "flex items-center justify-center gap-2 mb-3"'
+ ' (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")'
+ ' (when pr (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))'
+ ' (raw! eh))',
+ pr=post.get("publish_requested"), eh=edit_html,
+ )
# Blog post chrome (not for pages)
+ chrome_html = ""
if not post.get("is_page"):
+ like_html = ""
if user:
liked = post.get("is_liked", False)
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
- parts.append(
- f''
- f'{"❤️" if liked else "🤍"}
'
+ like_html = sexp(
+ '(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"'
+ ' (button :hx-post lu :hx-swap "outerHTML"'
+ ' :hx-headers hh :class "cursor-pointer" heart))',
+ lu=like_url,
+ hh=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}',
+ heart="\u2764\ufe0f" if liked else "\U0001f90d",
)
+ excerpt_html = ""
if post.get("custom_excerpt"):
- parts.append(f'{post["custom_excerpt"]}
')
+ excerpt_html = sexp(
+ '(div :class "w-full text-center italic text-3xl p-2" ex)',
+ ex=post["custom_excerpt"],
+ )
- # Desktop at_bar
- parts.append(f'{_at_bar_html(post, ctx)}
')
+ at_bar = _at_bar_html(post, ctx)
+ chrome_html = sexp(
+ '(<> (raw! lh) (raw! exh) (div :class "hidden md:block" (raw! ab)))',
+ lh=like_html, exh=excerpt_html, ab=at_bar,
+ )
- # Feature image
fi = post.get("feature_image")
- if fi:
- parts.append(f'')
-
- # Post HTML content
html_content = post.get("html", "")
- if html_content:
- parts.append(f'{html_content}
')
- parts.append('
')
- return "".join(parts)
+ return sexp(
+ '(<> (article :class "relative"'
+ ' (raw! dh) (raw! ch)'
+ ' (when fi (div :class "mb-3 flex justify-center"'
+ ' (img :src fi :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))'
+ ' (when hc (div :class "blog-content p-2" (raw! hc))))'
+ ' (div :class "pb-8"))',
+ dh=draft_html, ch=chrome_html,
+ fi=fi, hc=html_content,
+ )
def _post_meta_html(ctx: dict) -> str:
@@ -1006,27 +1081,27 @@ def _post_meta_html(ctx: dict) -> str:
tw_title = post.get("twitter_title") or base_title
is_article = not post.get("is_page")
- parts = [f'
']
- parts.append(f'
{escape(base_title)} ')
- parts.append(f'
')
- if canonical:
- parts.append(f'
')
-
- parts.append(f'
')
- parts.append(f'
')
- parts.append(f'
')
- if canonical:
- parts.append(f'
')
- if image:
- parts.append(f'
')
-
- parts.append(f'
')
- parts.append(f'
')
- parts.append(f'
')
- if image:
- parts.append(f'
')
-
- return "".join(parts)
+ return sexp(
+ '(<>'
+ ' (meta :name "robots" :content robots)'
+ ' (title bt)'
+ ' (meta :name "description" :content desc)'
+ ' (when canon (link :rel "canonical" :href canon))'
+ ' (meta :property "og:type" :content ogt)'
+ ' (meta :property "og:title" :content og_title)'
+ ' (meta :property "og:description" :content desc)'
+ ' (when canon (meta :property "og:url" :content canon))'
+ ' (when image (meta :property "og:image" :content image))'
+ ' (meta :name "twitter:card" :content twc)'
+ ' (meta :name "twitter:title" :content tw_title)'
+ ' (meta :name "twitter:description" :content desc)'
+ ' (when image (meta :name "twitter:image" :content image)))',
+ robots=robots, bt=base_title, desc=desc, canon=canonical,
+ ogt="article" if is_article else "website",
+ og_title=og_title, image=image,
+ twc="summary_large_image" if image else "summary",
+ tw_title=tw_title,
+ )
# ---------------------------------------------------------------------------
@@ -1037,7 +1112,7 @@ 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'
{html}
'
+ return sexp('(article :class "relative" (div :class "blog-content p-2" (raw! h)))', h=html)
# ---------------------------------------------------------------------------
@@ -1045,7 +1120,7 @@ def _home_main_panel_html(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _post_admin_main_panel_html(ctx: dict) -> str:
- return '
'
+ return sexp('(div :class "pb-8")')
# ---------------------------------------------------------------------------
@@ -1053,7 +1128,7 @@ def _post_admin_main_panel_html(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _settings_main_panel_html(ctx: dict) -> str:
- return '
'
+ return sexp('(div :class "max-w-2xl mx-auto px-4 py-6")')
def _cache_main_panel_html(ctx: dict) -> str:
@@ -1061,15 +1136,14 @@ def _cache_main_panel_html(ctx: dict) -> str:
csrf = ctx.get("csrf_token", "")
clear_url = qurl("settings.cache_clear")
- return (
- f'
'
+ return sexp(
+ '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"'
+ ' (div :class "flex flex-col md:flex-row gap-3 items-start"'
+ ' (form :hx-post cu :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))'
+ ' (div :id "cache-status" :class "py-2")))',
+ cu=clear_url, csrf=csrf,
)
@@ -1078,11 +1152,13 @@ def _cache_main_panel_html(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _snippets_main_panel_html(ctx: dict) -> str:
- return (
- f'
'
- f'
'
- f'
Snippets '
- f'
{_snippets_list_html(ctx)}
'
+ sl = _snippets_list_html(ctx)
+ return sexp(
+ '(div :class "max-w-4xl mx-auto p-6"'
+ ' (div :class "mb-6 flex justify-between items-center"'
+ ' (h1 :class "text-3xl font-bold" "Snippets"))'
+ ' (div :id "snippets-list" (raw! sl)))',
+ sl=sl,
)
@@ -1097,11 +1173,11 @@ def _snippets_list_html(ctx: dict) -> str:
user_id = getattr(user, "id", None)
if not snippets:
- return (
- '
'
- '
'
- '
'
- '
No snippets yet. Create one from the blog editor.
'
+ return sexp(
+ '(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")'
+ ' (p "No snippets yet. Create one from the blog editor.")))',
)
badge_colours = {
@@ -1110,7 +1186,7 @@ def _snippets_list_html(ctx: dict) -> str:
"admin": "bg-amber-100 text-amber-700",
}
- parts = ['
']
+ row_parts = []
for s in snippets:
s_id = getattr(s, "id", None) or s.get("id")
s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "")
@@ -1120,41 +1196,52 @@ def _snippets_list_html(ctx: dict) -> str:
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'
'
- f'
{escape(s_name)}
'
- f'
{owner}
'
- f'
{s_vis} '
- )
-
+ extra = ""
if is_admin:
patch_url = qurl("snippets.patch_visibility", snippet_id=s_id)
- parts.append(
- f'
'
- )
+ opts = ""
for v in ["private", "shared", "admin"]:
- sel = " selected" if s_vis == v else ""
- parts.append(f'{v} ')
- parts.append(' ')
+ opts += sexp(
+ '(option :value v :selected sel v)',
+ v=v, sel=(s_vis == v),
+ )
+ extra += sexp(
+ '(select :name "visibility" :hx-patch pu :hx-target "#snippets-list" :hx-swap "innerHTML"'
+ ' :hx-headers hh :class "text-sm border border-stone-300 rounded px-2 py-1"'
+ ' (raw! opts))',
+ pu=patch_url, hh=f'{{"X-CSRFToken": "{csrf}"}}', opts=opts,
+ )
if s_uid == user_id or is_admin:
del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
- parts.append(
- f'
'
- f' Delete '
+ extra += sexp(
+ '(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"'
+ ' :data-confirm-text ct :data-confirm-icon "warning"'
+ ' :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"'
+ ' :data-confirm-event "confirmed"'
+ ' :hx-delete du :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML"'
+ ' :hx-headers hh'
+ ' :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"'
+ ' (i :class "fa fa-trash") " Delete")',
+ ct=f'Delete \u201c{s_name}\u201d?',
+ du=del_url, hh=f'{{"X-CSRFToken": "{csrf}"}}',
)
- parts.append('
')
+ row_parts.append(sexp(
+ '(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"'
+ ' (div :class "flex-1 min-w-0"'
+ ' (div :class "font-medium truncate" sn)'
+ ' (div :class "text-xs text-stone-500" ow))'
+ ' (span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " bc) sv)'
+ ' (raw! ex))',
+ sn=s_name, ow=owner, bc=badge_cls, sv=s_vis, ex=extra,
+ ))
- parts.append('
')
- return "".join(parts)
+ rows = "".join(row_parts)
+ return sexp(
+ '(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows)))',
+ rows=rows,
+ )
# ---------------------------------------------------------------------------
@@ -1165,14 +1252,16 @@ 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'
'
- f'
'
- f''
- f' Add Menu Item
'
- f''
- f'
'
+ ml = _menu_items_list_html(ctx)
+ return sexp(
+ '(div :class "max-w-4xl mx-auto p-6"'
+ ' (div :class "mb-6 flex justify-end items-center"'
+ ' (button :type "button" :hx-get nu :hx-target "#menu-item-form" :hx-swap "innerHTML"'
+ ' :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"'
+ ' (i :class "fa fa-plus") " Add Menu Item"))'
+ ' (div :id "menu-item-form" :class "mb-6")'
+ ' (div :id "menu-items-list" (raw! ml)))',
+ nu=new_url, ml=ml,
)
@@ -1183,14 +1272,14 @@ def _menu_items_list_html(ctx: dict) -> str:
csrf = ctx.get("csrf_token", "")
if not menu_items:
- return (
- '
'
- '
'
- '
'
- '
No menu items yet. Add one to get started!
'
+ return sexp(
+ '(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")'
+ ' (p "No menu items yet. Add one to get started!")))',
)
- parts = ['
']
+ row_parts = []
for item in menu_items:
i_id = getattr(item, "id", None) or item.get("id")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
@@ -1201,31 +1290,43 @@ def _menu_items_list_html(ctx: dict) -> str:
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'
'
- if fi else '
')
-
- parts.append(
- f'
'
- f'
'
- f'{img}'
- f'
{escape(label)}
'
- f'
{escape(slug)}
'
- f'
Order: {sort}
'
- f'
'
- f' Edit '
- f''
- f' Delete
'
+ img_html = sexp(
+ '(if fi (img :src fi :alt lb :class "w-12 h-12 rounded-full object-cover flex-shrink-0")'
+ ' (div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"))',
+ fi=fi, lb=label,
)
- parts.append('
')
- return "".join(parts)
+ row_parts.append(sexp(
+ '(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"'
+ ' (div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))'
+ ' (raw! img)'
+ ' (div :class "flex-1 min-w-0"'
+ ' (div :class "font-medium truncate" lb)'
+ ' (div :class "text-xs text-stone-500 truncate" sl))'
+ ' (div :class "text-sm text-stone-500" (str "Order: " so))'
+ ' (div :class "flex gap-2 flex-shrink-0"'
+ ' (button :type "button" :hx-get eu :hx-target "#menu-item-form" :hx-swap "innerHTML"'
+ ' :class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"'
+ ' (i :class "fa fa-edit") " Edit")'
+ ' (button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"'
+ ' :data-confirm-text ct :data-confirm-icon "warning"'
+ ' :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"'
+ ' :data-confirm-event "confirmed"'
+ ' :hx-delete du :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML"'
+ ' :hx-headers hh'
+ ' :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"'
+ ' (i :class "fa fa-trash") " Delete")))',
+ img=img_html, lb=label, sl=slug, so=str(sort),
+ eu=edit_url, du=del_url,
+ ct=f"Remove {label} from the menu?",
+ hh=f'{{"X-CSRFToken": "{csrf}"}}',
+ ))
+
+ rows = "".join(row_parts)
+ return sexp(
+ '(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows)))',
+ rows=rows,
+ )
# ---------------------------------------------------------------------------
@@ -1239,27 +1340,24 @@ def _tag_groups_main_panel_html(ctx: dict) -> str:
unassigned_tags = ctx.get("unassigned_tags") or []
csrf = ctx.get("csrf_token", "")
- parts = ['
']
-
- # Create form
create_url = qurl("blog.tag_groups_admin.create")
- parts.append(
- f'
'
+ form_html = sexp(
+ '(form :method "post" :action cu :class "border rounded p-4 bg-white space-y-3"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (h3 :class "text-sm font-semibold text-stone-700" "New Group")'
+ ' (div :class "flex flex-col sm:flex-row gap-3"'
+ ' (input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")'
+ ' (input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")'
+ ' (input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))'
+ ' (input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")'
+ ' (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create"))',
+ cu=create_url, csrf=csrf,
)
# Groups list
+ groups_html = ""
if groups:
- parts.append('
')
+ li_parts = []
for group in groups:
g_id = getattr(group, "id", None) or group.get("id")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
@@ -1271,38 +1369,57 @@ def _tag_groups_main_panel_html(ctx: dict) -> str:
edit_href = qurl("blog.tag_groups_admin.edit", id=g_id)
if g_fi:
- icon = f' '
+ icon = sexp(
+ '(img :src fi :alt n :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")',
+ fi=g_fi, n=g_name,
+ )
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
- icon = (
- f'{escape(g_name[:1])}
'
+ icon = sexp(
+ '(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"'
+ ' :style st i)',
+ st=style, i=g_name[:1],
)
- parts.append(
- f'{icon}'
- f''
- f'order: {g_sort} '
- )
- parts.append(' ')
+ li_parts.append(sexp(
+ '(li :class "border rounded p-3 bg-white flex items-center gap-3"'
+ ' (raw! ic)'
+ ' (div :class "flex-1"'
+ ' (a :href eh :class "font-medium text-stone-800 hover:underline" gn)'
+ ' (span :class "text-xs text-stone-500 ml-2" gs))'
+ ' (span :class "text-xs text-stone-500" (str "order: " so)))',
+ ic=icon, eh=edit_href, gn=g_name, gs=g_slug, so=str(g_sort),
+ ))
+ groups_html = sexp(
+ '(ul :class "space-y-2" (raw! items))',
+ items="".join(li_parts),
+ )
else:
- parts.append('
No tag groups yet.
')
+ groups_html = sexp('(p :class "text-stone-500 text-sm" "No tag groups yet.")')
# Unassigned tags
+ unassigned_html = ""
if unassigned_tags:
- parts.append(f'
Unassigned Tags ({len(unassigned_tags)}) ')
- parts.append('
')
+ tag_spans = []
for tag in unassigned_tags:
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
- parts.append(
- f''
- f'{escape(t_name)} '
- )
- parts.append('
')
+ tag_spans.append(sexp(
+ '(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" tn)',
+ tn=t_name,
+ ))
+ unassigned_html = sexp(
+ '(div :class "border-t pt-4"'
+ ' (h3 :class "text-sm font-semibold text-stone-700 mb-2" hd)'
+ ' (div :class "flex flex-wrap gap-2" (raw! spans)))',
+ hd=f"Unassigned Tags ({len(unassigned_tags)})",
+ spans="".join(tag_spans),
+ )
- parts.append('
')
- return "".join(parts)
+ return sexp(
+ '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"'
+ ' (raw! fh) (raw! gh) (raw! uh))',
+ fh=form_html, gh=groups_html, uh=unassigned_html,
+ )
def _tag_groups_edit_main_panel_html(ctx: dict) -> str:
@@ -1322,57 +1439,60 @@ def _tag_groups_edit_main_panel_html(ctx: dict) -> str:
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'
']
-
- # Edit form
- parts.append(
- f'
'
+ edit_form = sexp(
+ '(form :method "post" :action su :class "border rounded p-4 bg-white space-y-4"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (div :class "space-y-3"'
+ ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")'
+ ' (input :type "text" :name "name" :value gn :required "" :class "w-full border rounded px-3 py-2 text-sm"))'
+ ' (div :class "flex gap-3"'
+ ' (div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")'
+ ' (input :type "text" :name "colour" :value gc :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))'
+ ' (div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")'
+ ' (input :type "number" :name "sort_order" :value gs :class "w-full border rounded px-3 py-2 text-sm")))'
+ ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")'
+ ' (input :type "text" :name "feature_image" :value gfi :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))'
+ ' (div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")'
+ ' (div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"'
+ ' (raw! tags)))'
+ ' (div :class "flex gap-3"'
+ ' (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save")))',
+ su=save_url, csrf=csrf,
+ gn=g_name, gc=g_colour or "", gs=str(g_sort), gfi=g_fi or "",
+ tags="".join(tag_items),
)
- # Delete form
- parts.append(
- f'
'
+ del_form = sexp(
+ '(form :method "post" :action du :class "border-t pt-4"'
+ ' :onsubmit "return confirm(\'Delete this tag group? Tags will not be deleted.\')"'
+ ' (input :type "hidden" :name "csrf_token" :value csrf)'
+ ' (button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group"))',
+ du=del_url, csrf=csrf,
)
- parts.append('
')
- return "".join(parts)
+ return sexp(
+ '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"'
+ ' (raw! ef) (raw! df))',
+ ef=edit_form, df=del_form,
+ )
# ---------------------------------------------------------------------------
@@ -1477,98 +1597,75 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
# Error banner
if save_error:
- parts.append(
- '
'
- f'Save failed: {esc(save_error)}
'
- )
+ parts.append(sexp(
+ '(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"'
+ ' (strong "Save failed:") " " err)',
+ err=str(save_error),
+ ))
- # Form
- parts.append(
- '
'
+ ' :title "Remove feature image"'
+ ' (i :class "fa-solid fa-trash-can"))'
+ ' (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 :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") " Uploading...")'
+ ' (input :type "file" :id "feature-image-file"'
+ ' :accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))'
+ ' (input :type "text" :name "title" :value "" :placeholder tp'
+ ' :class "w-full text-[36px] font-bold bg-transparent border-none outline-none'
+ ' placeholder:text-stone-300 mb-[8px] leading-tight")'
+ ' (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")'
+ ' (div :id "lexical-editor" :class "relative w-full bg-transparent")'
+ ' (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 t "Draft")'
+ ' (option :value "published" "Published"))'
+ ' (button :type "submit"'
+ ' :class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]'
+ ' hover:bg-stone-800 transition-colors cursor-pointer" cl)))',
+ csrf=csrf, tp=title_placeholder, cl=create_label, t=True,
)
+ parts.append(form_html)
# Editor CSS + inline styles
- parts.append(
- f'
'
- ''
- )
+ parts.append(sexp(
+ '(<> (link :rel "stylesheet" :href ecss)'
+ ' (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; }"))',
+ ecss=editor_css,
+ ))
- # Editor JS + init script
- # NOTE: JavaScript string literals use single quotes; Python f-string injects URLs.
+ # Editor JS + init script — kept as raw HTML due to complex JS with quotes
+ parts.append(sexp('(script :src ejs)', ejs=editor_js))
parts.append(
- f''
"