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'
' - f'
{row_html}' - f'
' + return sexp( + '(div :id pid :hx-swap-oob "outerHTML" :class "w-full"' + ' (div :class "w-full" (raw! rh)' + ' (div :id cid)))', + pid=parent_id, rh=row_html, cid=child_id, ) @@ -42,7 +43,7 @@ def _blog_header_html(ctx: dict, *, oob: bool = False) -> str: '(~menu-row :id "blog-row" :level 1' ' :link-label-html llh' ' :child-id "blog-header-child" :oob oob)', - llh="
", + llh=sexp('(div)'), oob=oob, ) @@ -58,32 +59,32 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: title = (post.get("title") or "")[:160] feature_image = post.get("feature_image") - label_parts = [] - if feature_image: - label_parts.append( - f'' - ) - label_parts.append(f"{escape(title)}") - label_html = "".join(label_parts) + label_html = sexp( + '(<> (when fi (img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))' + ' (span t))', + fi=feature_image, t=title, + ) 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'' - f'' - f'{page_cart_count}' - ) + nav_parts.append(sexp( + '(a :href h :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full' + ' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"' + ' (i :class "fa fa-shopping-cart" :aria-hidden "true")' + ' (span c))', + h=cart_href, c=str(page_cart_count), + )) # Container nav fragments (calendars, markets) container_nav = ctx.get("container_nav_html", "") if container_nav: - nav_parts.append( - f'
{container_nav}
' - ) + nav_parts.append(sexp( + '(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" (raw! cn))', + cn=container_nav, + )) # Admin link from quart import url_for as qurl, g, request @@ -95,15 +96,14 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: 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'' - ) + nav_parts.append(sexp( + '(~nav-link :href h :hx-select "#main-panel" :icon "fa fa-cog"' + ' :aclass ac :select-colours sc :is-selected sel)', + h=admin_href, + ac=f"{nav_btn} {select_colours}", + sc=select_colours, + sel=is_admin_page, + )) nav_html = "".join(nav_parts) link_href = call_url(ctx, "blog_url", f"/{slug}/") @@ -135,7 +135,9 @@ def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str: 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 = ' admin' + label_html = sexp( + '(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")', + ) nav_html = _post_admin_nav_html(ctx) @@ -172,10 +174,10 @@ def _post_admin_nav_html(ctx: dict) -> str: (f"/{slug}/payments/", "payments"), ]: href = events_url_fn(path) - parts.append( - f'' - ) + parts.append(sexp( + '(div :class "relative nav-group" (a :href h :class c l))', + h=href, c=nav_btn, l=label, + )) # HTMX links for endpoint, label in [ @@ -203,7 +205,9 @@ def _settings_header_html(ctx: dict, *, oob: bool = False) -> str: hx_select = ctx.get("hx_select_search", "#main-panel") settings_href = qurl("settings.home") - label_html = ' admin' + label_html = sexp( + '(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")', + ) nav_html = _settings_nav_html(ctx) @@ -249,7 +253,10 @@ def _sub_settings_header_html(row_id: str, child_id: str, href: str, *, 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' {escape(label)}' + label_html = sexp( + '(<> (i :class ic :aria-hidden "true") " " l)', + ic=f"fa fa-{icon}", l=label, + ) return sexp( '(~menu-row :id rid :level 2' @@ -268,7 +275,10 @@ 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'
{escape(label)}
' + label_html = sexp( + '(<> (i :class ic :aria-hidden "true") (div l))', + ic=f"fa fa-{icon}", l=label, + ) return sexp( '(~menu-row :id rid :level 3' @@ -310,70 +320,66 @@ def _blog_card_html(post: dict, ctx: dict) -> str: hx_select = ctx.get("hx_select_search", "#main-panel") user = getattr(g, "user", None) - parts = ['
'] - - # Like button + 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'
' + like_html = sexp( + '(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"' + ' (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", ) - parts.append( - f'' - ) - - # Header - parts.append(f'

{escape(post.get("title", ""))}

') - status = post.get("status", "published") + status_html = "" if status == "draft": - parts.append('
') - parts.append('Draft') - if post.get("publish_requested"): - parts.append('Publish requested') - parts.append('
') + pub_req = post.get("publish_requested") updated = post.get("updated_at") + ts = "" if updated: ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) - parts.append(f'

Updated: {ts}

') + status_html = sexp( + '(<> (div :class "flex justify-center gap-2 mt-1"' + ' (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")' + ' (when pr (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))' + ' (when ts (p :class "text-sm text-stone-500" (str "Updated: " ts))))', + pr=pub_req, ts=ts, + ) else: pub = post.get("published_at") if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) - parts.append(f'

Published: {ts}

') + status_html = sexp( + '(p :class "text-sm text-stone-500" (str "Published: " ts))', + ts=ts, + ) - parts.append('
') - - # Feature image fi = post.get("feature_image") - if fi: - parts.append(f'
') - - # Excerpt excerpt = post.get("custom_excerpt") or post.get("excerpt", "") - if excerpt: - parts.append(f'

{escape(excerpt)}

') - - parts.append('
') - - # Card widgets (fragments) card_widgets = ctx.get("card_widgets_html") or {} widget = card_widgets.get(str(post.get("id", "")), "") - if widget: - parts.append(widget) + at_bar = _at_bar_html(post, ctx) - # Tags + authors bar - parts.append(_at_bar_html(post, ctx)) - parts.append('
') - return "".join(parts) + return sexp( + '(article :class "border-b pb-6 last:border-b-0 relative"' + ' (raw! like_html)' + ' (a :href h :hx-get h :hx-target "#main-panel"' + ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' + ' :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"' + ' (header :class "mb-2 text-center"' + ' (h2 :class "text-4xl font-bold text-stone-900" t)' + ' (raw! sh))' + ' (when fi (div :class "mb-4" (img :src fi :alt "" :class "rounded-lg w-full object-cover")))' + ' (when ex (p :class "text-stone-700 text-lg leading-relaxed text-center" ex)))' + ' (when wid (raw! wid))' + ' (raw! ab))', + like_html=like_html, h=href, hs=hx_select, + t=post.get("title", ""), sh=status_html, + fi=fi, ex=excerpt, wid=widget, ab=at_bar, + ) def _blog_card_tile_html(post: dict, ctx: dict) -> str: @@ -382,45 +388,46 @@ def _blog_card_tile_html(post: dict, ctx: dict) -> str: href = call_url(ctx, "blog_url", f"/{slug}/") hx_select = ctx.get("hx_select_search", "#main-panel") - parts = ['
'] - parts.append( - f'' - ) - fi = post.get("feature_image") - if fi: - parts.append(f'
') - - parts.append('
') - parts.append(f'

{escape(post.get("title", ""))}

') - status = post.get("status", "published") + + status_html = "" if status == "draft": - parts.append('
') - parts.append('Draft') - if post.get("publish_requested"): - parts.append('Publish requested') - parts.append('
') updated = post.get("updated_at") + ts = "" if updated: ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) - parts.append(f'

Updated: {ts}

') + status_html = sexp( + '(<> (div :class "flex justify-center gap-1 mt-1"' + ' (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")' + ' (when pr (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))' + ' (when ts (p :class "text-sm text-stone-500" (str "Updated: " ts))))', + pr=post.get("publish_requested"), ts=ts, + ) else: pub = post.get("published_at") if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) - parts.append(f'

Published: {ts}

') + status_html = sexp('(p :class "text-sm text-stone-500" (str "Published: " ts))', ts=ts) excerpt = post.get("custom_excerpt") or post.get("excerpt", "") - if excerpt: - parts.append(f'

{escape(excerpt)}

') + at_bar = _at_bar_html(post, ctx) - parts.append('
') - parts.append(_at_bar_html(post, ctx)) - parts.append('
') - return "".join(parts) + return sexp( + '(article :class "relative"' + ' (a :href h :hx-get h :hx-target "#main-panel"' + ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' + ' :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"' + ' (when fi (div (img :src fi :alt "" :class "w-full aspect-video object-cover")))' + ' (div :class "p-3 text-center"' + ' (h2 :class "text-lg font-bold text-stone-900" t)' + ' (raw! sh)' + ' (when ex (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" ex))))' + ' (raw! ab))', + h=href, hs=hx_select, fi=fi, + t=post.get("title", ""), sh=status_html, + ex=excerpt, ab=at_bar, + ) def _at_bar_html(post: dict, ctx: dict) -> str: @@ -430,51 +437,64 @@ def _at_bar_html(post: dict, ctx: dict) -> str: 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 = ['
'] - + tag_items = "" if tags: - parts.append('
in
') - - parts.append('
') + else: + init = (t_name[:1]) if t_name else "" + icon = sexp( + '(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center' + ' border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" i)', + i=init, + ) + tag_li.append(sexp( + '(li (a :class "flex items-center gap-1" (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)))', + ic=icon, n=t_name, + )) + tag_items = sexp( + '(div :class "mt-4 flex items-center gap-2" (div "in")' + ' (ul :class "flex flex-wrap gap-2 text-sm" (raw! items)))', + items="".join(tag_li), + ) + author_items = "" if authors: - parts.append('
by
') + author_li.append(sexp( + '(li :class "text-stone-700" n)', n=a_name, + )) + author_items = sexp( + '(div :class "mt-4 flex items-center gap-2" (div "by")' + ' (ul :class "flex flex-wrap gap-2 text-sm" (raw! items)))', + items="".join(author_li), + ) - parts.append('
') - return "".join(parts) + return sexp( + '(div :class "flex flex-row justify-center gap-3"' + ' (raw! ti) (div) (raw! ai))', + ti=tag_items, ai=author_items, + ) def _blog_sentinel_html(ctx: dict) -> str: @@ -485,54 +505,61 @@ def _blog_sentinel_html(ctx: dict) -> str: total_pages = int(total_pages) if page >= total_pages: - return '
End of results
' + return sexp('(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")') 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'' + mobile_hs = ( + "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end" + " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end" + " on resize from window if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end" + " on htmx:beforeRequest if window.matchMedia('(min-width: 768px)').matches then halt end" + " add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me" + " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end" + " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me" + " wait ms ms trigger sentinelmobile:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end" + " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()" ) - # Desktop sentinel - parts.append( - f'' + + desktop_hs = ( + "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end" + " on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me" + " remove .opacity-100 from me add .opacity-0 to me" + " set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end" + " if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport" + " if scroller is null then halt end if scroller.scrollTop < 20 then halt end end" + " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end" + " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me" + " wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end" + " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()" ) - return "".join(parts) + + mobile = sexp( + '(div :id mid :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"' + ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"' + ' :hx-swap "outerHTML" :_ mhs' + ' :role "status" :aria-live "polite" :aria-hidden "true"' + ' (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 :class "js-neterr hidden text-center py-8 text-stone-400"' + ' (i :class "fa fa-exclamation-triangle text-2xl")' + ' (p :class "mt-2" "Loading failed \u2014 retrying\u2026")))', + mid=f"sentinel-{page}-m", nu=next_url, mhs=mobile_hs, + ) + + desktop = sexp( + '(div :id did :class "hidden md:block h-4 opacity-0 pointer-events-none"' + ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinel:retry"' + ' :hx-swap "outerHTML" :_ dhs' + ' :role "status" :aria-live "polite" :aria-hidden "true"' + ' (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 :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026"))', + did=f"sentinel-{page}-d", nu=next_url, dhs=desktop_hs, + ) + + return mobile + desktop def _page_cards_html(ctx: dict) -> str: @@ -551,14 +578,15 @@ def _page_cards_html(ctx: dict) -> str: 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'
' - ) + parts.append(sexp( + '(div :id sid :class "h-4 opacity-0 pointer-events-none"' + ' :hx-get nu :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML")', + sid=f"sentinel-{page_num}-d", nu=next_url, + )) elif pages: - parts.append('
End of results
') + parts.append(sexp('(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")')) else: - parts.append('
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 = ['
'] - parts.append( - f'' - ) - parts.append(f'

{escape(page.get("title", ""))}

') - - # Feature badges features = page.get("features") or {} + badges_html = "" if features: - parts.append('
') - if features.get("calendar"): - parts.append('Calendar') - if features.get("market"): - parts.append('Market') - parts.append('
') + badges_html = sexp( + '(div :class "flex justify-center gap-2 mt-2"' + ' (when cal (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") "Calendar"))' + ' (when mkt (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") "Market")))', + cal=features.get("calendar"), mkt=features.get("market"), + ) pub = page.get("published_at") + pub_html = "" if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) - parts.append(f'

Published: {ts}

') - - parts.append('
') + pub_html = sexp('(p :class "text-sm text-stone-500" (str "Published: " ts))', ts=ts) fi = page.get("feature_image") - if fi: - parts.append(f'
') - excerpt = page.get("custom_excerpt") or page.get("excerpt", "") - if excerpt: - parts.append(f'

{escape(excerpt)}

') - parts.append('
') - return "".join(parts) + return sexp( + '(article :class "border-b pb-6 last:border-b-0 relative"' + ' (a :href h :hx-get h :hx-target "#main-panel"' + ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' + ' :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"' + ' (header :class "mb-2 text-center"' + ' (h2 :class "text-4xl font-bold text-stone-900" t)' + ' (raw! bh) (raw! ph))' + ' (when fi (div :class "mb-4" (img :src fi :alt "" :class "rounded-lg w-full object-cover")))' + ' (when ex (p :class "text-stone-700 text-lg leading-relaxed text-center" ex))))', + h=href, hs=hx_select, t=page.get("title", ""), + bh=badges_html, ph=pub_html, fi=fi, ex=excerpt, + ) def _view_toggle_html(ctx: dict) -> str: @@ -618,18 +645,28 @@ def _view_toggle_html(ctx: dict) -> str: list_href = f"{current_local_href}" tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile" - list_svg = '' - tile_svg = '' + list_svg = sexp( + '(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"))', + ) + tile_svg = sexp( + '(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"))', + ) - return ( - f'' + return sexp( + '(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"' + ' (a :href lh :hx-get lh :hx-target "#main-panel" :hx-select hs' + ' :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " lc) :title "List view"' + ' :_ "on click js localStorage.removeItem(\'blog_view\') end" (raw! ls))' + ' (a :href th :hx-get th :hx-target "#main-panel" :hx-select hs' + ' :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tc) :title "Tile view"' + ' :_ "on click js localStorage.setItem(\'blog_view\',\'tile\') end" (raw! ts)))', + lh=list_href, th=tile_href, hs=hx_select, + lc=list_cls, tc=tile_cls, ls=list_svg, ts=tile_svg, ) @@ -646,15 +683,16 @@ def _content_type_tabs_html(ctx: dict) -> str: 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'
' - f'Posts' - f'Pages' - f'
' + return sexp( + '(div :class "flex justify-center gap-1 px-3 pt-3"' + ' (a :href ph :hx-get ph :hx-target "#main-panel"' + ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' + ' :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pc) "Posts")' + ' (a :href pgh :hx-get pgh :hx-target "#main-panel"' + ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' + ' :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pgc) "Pages"))', + ph=posts_href, pgh=pages_href, hs=hx_select, + pc=posts_cls, pgc=pages_cls, ) @@ -663,22 +701,22 @@ def _blog_main_panel_html(ctx: dict) -> str: content_type = ctx.get("content_type", "posts") view = ctx.get("view") - parts = [_content_type_tabs_html(ctx)] + tabs = _content_type_tabs_html(ctx) if content_type == "pages": - parts.append('
') - parts.append(_page_cards_html(ctx)) - parts.append('
') + cards = _page_cards_html(ctx) + return sexp( + '(<> (raw! tabs) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards)) (div :class "pb-8"))', + tabs=tabs, cards=cards, + ) else: - parts.append(_view_toggle_html(ctx)) - if view == "tile": - parts.append('
') - 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 = ['') - 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 = ['') - 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'
' + 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 = _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'
' - f'
' - f'
' - f'' - f'' - f'
' - f'
' - 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 += 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'' + 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'' - 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'{escape(label)}' - if fi else '
') - - parts.append( - f'
' - f'
' - f'{img}' - f'
{escape(label)}
' - f'
{escape(slug)}
' - f'
Order: {sort}
' - f'
' - f'' - f'
' + 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'
' - f'' - f'

New Group

' - f'
' - f'' - f'' - f'' - f'
' - f'' - f'' - 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.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'
' - f'' - f'
' - f'
' - f'
' - f'
' - f'
' - f'
' - f'
' - f'
' - f'
' - f'
' - ) - # Tag checkboxes - parts.append( - '
' - '
' - ) + tag_items = [] for tag in all_tags: t_id = getattr(tag, "id", None) or tag.get("id") t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image") - checked = " checked" if t_id in assigned_tag_ids else "" - img = f'' if t_fi else "" - parts.append( - f'' - ) - parts.append('
') + checked = t_id in assigned_tag_ids + img = sexp( + '(img :src fi :alt "" :class "h-4 w-4 rounded-full object-cover")', + fi=t_fi, + ) if t_fi else "" + tag_items.append(sexp( + '(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"' + ' (input :type "checkbox" :name "tag_ids" :value tid :checked ch :class "rounded border-stone-300")' + ' (raw! im) (span tn))', + tid=str(t_id), ch=checked, im=img, tn=t_name, + )) - parts.append( - '
' - '
' + 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'
' - f'' - 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( - '
' - f'' - '' - '' - '' - ) - - # Feature image section - parts.append( - '
' - # Empty state - '
' - '
' - # Filled state - '' - # Upload spinner - '' - # Hidden file input - '' - '
' - ) - - # Title - parts.append( - f'' - ) - - # Excerpt - parts.append( - '' - ) - - # Editor mount point - parts.append('
') - - # Status + Save footer - 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'' "