""" Blog service s-expression page components. Renders home, blog index (posts/pages), new post/page, post detail, post admin, post data, post entries, post edit, post settings, settings home, cache, snippets, menu items, and tag groups pages. Called from route handlers in place of ``render_template()``. """ from __future__ import annotations import os from typing import Any from markupsafe import escape from shared.sx.jinja_bridge import load_service_components from shared.sx.parser import serialize as sx_serialize from shared.sx.helpers import ( SxExpr, sx_call, call_url, get_asset_url, root_header_sx, post_header_sx, post_admin_header_sx, header_child_sx, oob_header_sx, oob_page_sx, search_mobile_sx, search_desktop_sx, full_page_sx, mobile_menu_sx, mobile_root_nav_sx, post_mobile_nav_sx, post_admin_mobile_nav_sx, ) # Load blog service .sx component definitions + handler definitions load_service_components(os.path.dirname(os.path.dirname(__file__)), service_name="blog") def _ctx_csrf(ctx: dict) -> str: """Get CSRF token from context, handling Jinja callable globals.""" val = ctx.get("csrf_token", "") return val() if callable(val) else val # --------------------------------------------------------------------------- # OOB header helper — delegates to shared # --------------------------------------------------------------------------- _oob_header_sx = oob_header_sx # --------------------------------------------------------------------------- # Blog header (root-header-child -> blog-header-child) # --------------------------------------------------------------------------- def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str: """Blog header row — empty child of root.""" return sx_call("menu-row-sx", id="blog-row", level=1, link_label_content=SxExpr("(div)"), child_id="blog-header-child", oob=oob, ) # --------------------------------------------------------------------------- # Post header helpers — thin wrapper over shared post_header_sx # --------------------------------------------------------------------------- def _post_header_sx(ctx: dict, *, oob: bool = False) -> str: """Build the post-level header row as sx — delegates to shared helper.""" return post_header_sx(ctx, oob=oob) # --------------------------------------------------------------------------- # Post admin header # --------------------------------------------------------------------------- def _post_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str: """Post admin header row as sx — delegates to shared helper.""" slug = (ctx.get("post") or {}).get("slug", "") return post_admin_header_sx(ctx, slug, oob=oob, selected=selected) def _post_admin_mobile_menu(ctx: dict, selected: str = "") -> str: """Full mobile menu for any post admin page (admin + post + root).""" slug = (ctx.get("post") or {}).get("slug", "") return mobile_menu_sx( post_admin_mobile_nav_sx(ctx, slug, selected), post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx), ) # --------------------------------------------------------------------------- # Settings header (root-header-child -> root-settings-header-child) # --------------------------------------------------------------------------- def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str: """Settings header row with admin icon and nav links (sx).""" from quart import url_for as qurl settings_href = qurl("settings.defpage_settings_home") label_sx = sx_call("blog-admin-label") nav_sx = _settings_nav_sx(ctx) return sx_call("menu-row-sx", id="root-settings-row", level=1, link_href=settings_href, link_label_content=SxExpr(label_sx), nav=SxExpr(nav_sx) if nav_sx else None, child_id="root-settings-header-child", oob=oob, ) def _settings_nav_sx(ctx: dict) -> str: """Settings desktop nav as sx.""" from quart import url_for as qurl select_colours = ctx.get("select_colours", "") parts = [] for endpoint, icon, label in [ ("menu_items.defpage_menu_items_page", "bars", "Menu Items"), ("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"), ("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"), ("settings.defpage_cache_page", "refresh", "Cache"), ]: href = qurl(endpoint) parts.append(sx_call("nav-link", href=href, icon=f"fa fa-{icon}", label=label, select_colours=select_colours, )) return "(<> " + " ".join(parts) + ")" if parts else "" # --------------------------------------------------------------------------- # Sub-settings headers (root-settings-header-child -> X-header-child) # --------------------------------------------------------------------------- def _sub_settings_header_sx(row_id: str, child_id: str, href: str, icon: str, label: str, ctx: dict, *, oob: bool = False, nav_sx: str = "") -> str: """Generic sub-settings header row as sx.""" label_sx = sx_call("blog-sub-settings-label", icon=f"fa fa-{icon}", label=label, ) return sx_call("menu-row-sx", id=row_id, level=2, link_href=href, link_label_content=SxExpr(label_sx), nav=SxExpr(nav_sx) if nav_sx else None, child_id=child_id, oob=oob, ) # --------------------------------------------------------------------------- # Blog index main panel helpers # --------------------------------------------------------------------------- def _blog_sentinel_sx(ctx: dict) -> str: """Infinite scroll sentinels as sx calls (for wire format).""" from shared.sx.helpers import sx_call page = ctx.get("page", 1) total_pages = ctx.get("total_pages", 1) if isinstance(total_pages, str): total_pages = int(total_pages) if page >= total_pages: return sx_call("end-of-results") current_local_href = ctx.get("current_local_href", "/index") next_url = f"{current_local_href}?page={page + 1}" mobile_hs = ( "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end" " if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end" " on resize from window if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end" " on htmx:beforeRequest if window.matchMedia('(min-width: 768px)').matches then halt end" " add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me" " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end" " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me" " wait ms ms trigger sentinelmobile:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end" " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()" ) desktop_hs = ( "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end" " on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me" " remove .opacity-100 from me add .opacity-0 to me" " set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end" " if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport" " if scroller is null then halt end if scroller.scrollTop < 20 then halt end end" " def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end" " add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me" " wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end" " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()" ) return ( sx_call("sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs) + " " + sx_call("sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs) ) def _blog_cards_sx(ctx: dict) -> str: """S-expression wire format for blog cards (client renders).""" posts = ctx.get("posts") or [] view = ctx.get("view") parts = [] for p in posts: if view == "tile": parts.append(_blog_card_tile_sx(p, ctx)) else: parts.append(_blog_card_sx(p, ctx)) parts.append(_blog_sentinel_sx(ctx)) return "(<> " + " ".join(parts) + ")" def _format_ts(dt) -> str: if not dt: return "" return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt) def _tag_data(tags: list) -> list[dict]: """Extract pure data for tags.""" result = [] for t in tags: name = t.get("name") or getattr(t, "name", "") fi = t.get("feature_image") or getattr(t, "feature_image", None) initial = (name[:1]) if name else "" result.append({"name": name, "src": fi or "", "initial": initial}) return result def _author_data(authors: list) -> list[dict]: """Extract pure data for authors.""" result = [] for a in authors: name = a.get("name") or getattr(a, "name", "") img = a.get("profile_image") or getattr(a, "profile_image", None) result.append({"name": name, "image": img or ""}) return result def _blog_card_sx(post: dict, ctx: dict) -> str: """Single blog card as sx call (wire format) — pure data, no HTML.""" from quart import g slug = post.get("slug", "") href = call_url(ctx, "blog_url", f"/{slug}/") hx_select = ctx.get("hx_select_search", "#main-panel") user = getattr(g, "user", None) status = post.get("status", "published") is_draft = status == "draft" if is_draft: status_timestamp = _format_ts(post.get("updated_at")) else: status_timestamp = _format_ts(post.get("published_at")) fi = post.get("feature_image") excerpt = post.get("custom_excerpt") or post.get("excerpt", "") card_widgets = ctx.get("card_widgets_html") or {} widget = card_widgets.get(str(post.get("id", "")), "") tags = _tag_data(post.get("tags") or []) authors = _author_data(post.get("authors") or []) kwargs = dict( slug=slug, href=href, hx_select=hx_select, title=post.get("title", ""), feature_image=fi, excerpt=excerpt, is_draft=is_draft, publish_requested=post.get("publish_requested", False) if is_draft else False, status_timestamp=status_timestamp, has_like=bool(user), ) if user: kwargs["liked"] = post.get("is_liked", False) kwargs["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") kwargs["csrf_token"] = _ctx_csrf(ctx) if tags: kwargs["tags"] = tags if authors: kwargs["authors"] = authors if widget: kwargs["widget"] = SxExpr(widget) if widget else None return sx_call("blog-card", **kwargs) def _blog_card_tile_sx(post: dict, ctx: dict) -> str: """Single blog card tile as sx call (wire format) — pure data.""" slug = post.get("slug", "") href = call_url(ctx, "blog_url", f"/{slug}/") hx_select = ctx.get("hx_select_search", "#main-panel") fi = post.get("feature_image") status = post.get("status", "published") is_draft = status == "draft" if is_draft: status_timestamp = _format_ts(post.get("updated_at")) else: status_timestamp = _format_ts(post.get("published_at")) excerpt = post.get("custom_excerpt") or post.get("excerpt", "") tags = _tag_data(post.get("tags") or []) authors = _author_data(post.get("authors") or []) kwargs = dict( href=href, hx_select=hx_select, feature_image=fi, title=post.get("title", ""), is_draft=is_draft, publish_requested=post.get("publish_requested", False) if is_draft else False, status_timestamp=status_timestamp, excerpt=excerpt, ) if tags: kwargs["tags"] = tags if authors: kwargs["authors"] = authors return sx_call("blog-card-tile", **kwargs) def _at_bar_sx(post: dict, ctx: dict) -> str: """Tags + authors bar below a card as sx.""" tags = post.get("tags") or [] authors = post.get("authors") or [] if not tags and not authors: return "" tag_data = [ {"src": t.get("feature_image") or getattr(t, "feature_image", None), "name": t.get("name") or getattr(t, "name", ""), "initial": (t.get("name") or getattr(t, "name", ""))[:1]} for t in tags ] if tags else [] author_data = [ {"image": a.get("profile_image") or getattr(a, "profile_image", None), "name": a.get("name") or getattr(a, "name", "")} for a in authors ] if authors else [] return sx_call("blog-at-bar", tags=tag_data, authors=author_data) def _page_cards_sx(ctx: dict) -> str: """Render page cards with sentinel (sx).""" pages = ctx.get("pages") or ctx.get("posts") or [] page_num = ctx.get("page", 1) total_pages = ctx.get("total_pages", 1) if isinstance(total_pages, str): total_pages = int(total_pages) hx_select = ctx.get("hx_select_search", "#main-panel") parts = [] for pg in pages: parts.append(_page_card_sx(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(sx_call("sentinel-simple", id=f"sentinel-{page_num}-d", next_url=next_url, )) elif pages: parts.append(sx_call("end-of-results")) else: parts.append(sx_call("blog-no-pages")) return "(<> " + " ".join(parts) + ")" if parts else "" def _page_card_sx(page: dict, ctx: dict) -> str: """Single page card as sx.""" slug = page.get("slug", "") href = call_url(ctx, "blog_url", f"/{slug}/") hx_select = ctx.get("hx_select_search", "#main-panel") features = page.get("features") or {} pub_timestamp = _format_ts(page.get("published_at")) fi = page.get("feature_image") excerpt = page.get("custom_excerpt") or page.get("excerpt", "") return sx_call("blog-page-card", href=href, hx_select=hx_select, title=page.get("title", ""), has_calendar=features.get("calendar", False), has_market=features.get("market", False), pub_timestamp=pub_timestamp, feature_image=fi, excerpt=excerpt, ) def _view_toggle_sx(ctx: dict) -> str: """View toggle bar (list/tile) for desktop.""" view = ctx.get("view") current_local_href = ctx.get("current_local_href", "/index") hx_select = ctx.get("hx_select_search", "#main-panel") list_cls = "bg-stone-200 text-stone-800" if view != "tile" else "text-stone-400 hover:text-stone-600" tile_cls = "bg-stone-200 text-stone-800" if view == "tile" else "text-stone-400 hover:text-stone-600" list_href = f"{current_local_href}" tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile" list_svg_sx = sx_call("list-svg") tile_svg_sx = sx_call("tile-svg") return sx_call("view-toggle", list_href=list_href, tile_href=tile_href, hx_select=hx_select, list_cls=list_cls, tile_cls=tile_cls, storage_key="blog_view", list_svg=SxExpr(list_svg_sx), tile_svg=SxExpr(tile_svg_sx), ) def _content_type_tabs_sx(ctx: dict) -> str: """Posts/Pages tabs.""" content_type = ctx.get("content_type", "posts") hx_select = ctx.get("hx_select_search", "#main-panel") posts_href = call_url(ctx, "blog_url", "/index") pages_href = f"{posts_href}?type=pages" posts_cls = "bg-stone-700 text-white" if content_type != "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200" pages_cls = "bg-stone-700 text-white" if content_type == "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200" return sx_call("blog-content-type-tabs", posts_href=posts_href, pages_href=pages_href, hx_select=hx_select, posts_cls=posts_cls, pages_cls=pages_cls, ) def _blog_main_panel_sx(ctx: dict) -> str: """Blog index main panel with tabs, toggle, and cards.""" content_type = ctx.get("content_type", "posts") view = ctx.get("view") tabs = _content_type_tabs_sx(ctx) if content_type == "pages": cards = _page_cards_sx(ctx) return sx_call("blog-main-panel-pages", tabs=SxExpr(tabs), cards=SxExpr(cards), ) else: toggle = _view_toggle_sx(ctx) grid_cls = "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" if view == "tile" else "max-w-full px-3 py-3 space-y-3" cards = _blog_cards_sx(ctx) return sx_call("blog-main-panel-posts", tabs=SxExpr(tabs), toggle=SxExpr(toggle), grid_cls=grid_cls, cards=SxExpr(cards), ) # --------------------------------------------------------------------------- # Desktop aside (filter sidebar) # --------------------------------------------------------------------------- def _blog_aside_sx(ctx: dict) -> str: """Desktop aside with search, action buttons, and filters.""" sd = search_desktop_sx(ctx) ab = _action_buttons_sx(ctx) tgf = _tag_groups_filter_sx(ctx) af = _authors_filter_sx(ctx) return sx_call("blog-aside", search=SxExpr(sd), action_buttons=SxExpr(ab), tag_groups_filter=SxExpr(tgf), authors_filter=SxExpr(af), ) def _blog_filter_sx(ctx: dict) -> str: """Mobile filter (details/summary).""" # Mobile filter summary tags summary_parts = [] tg_summary = _tag_groups_filter_summary_sx(ctx) au_summary = _authors_filter_summary_sx(ctx) if tg_summary: summary_parts.append(tg_summary) if au_summary: summary_parts.append(au_summary) search_sx = search_mobile_sx(ctx) if summary_parts: filter_content = "(<> " + search_sx + " " + " ".join(summary_parts) + ")" else: filter_content = search_sx action_buttons = _action_buttons_sx(ctx) tgf = _tag_groups_filter_sx(ctx) af = _authors_filter_sx(ctx) filter_details = "(<> " + tgf + " " + af + ")" return sx_call("mobile-filter", filter_summary=SxExpr(filter_content), action_buttons=SxExpr(action_buttons), filter_details=SxExpr(filter_details), ) def _action_buttons_sx(ctx: dict) -> str: """New Post/Page + Drafts toggle buttons (sx).""" from quart import g rights = ctx.get("rights") or {} has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) user = getattr(g, "user", None) hx_select = ctx.get("hx_select_search", "#main-panel") drafts = ctx.get("drafts") draft_count = ctx.get("draft_count", 0) current_local_href = ctx.get("current_local_href", "/index") parts = [] if has_admin: new_href = call_url(ctx, "blog_url", "/new/") parts.append(sx_call("blog-action-button", href=new_href, hx_select=hx_select, btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors", title="New Post", icon_class="fa fa-plus mr-1", label=" New Post", )) new_page_href = call_url(ctx, "blog_url", "/new-page/") parts.append(sx_call("blog-action-button", href=new_page_href, hx_select=hx_select, btn_class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors", title="New Page", icon_class="fa fa-plus mr-1", label=" New Page", )) if user and (draft_count or drafts): if drafts: off_href = f"{current_local_href}" parts.append(sx_call("blog-drafts-button", href=off_href, hx_select=hx_select, btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors", title="Hide Drafts", label=" Drafts ", draft_count=str(draft_count), )) else: on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1" parts.append(sx_call("blog-drafts-button-amber", href=on_href, hx_select=hx_select, btn_class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors", title="Show Drafts", label=" Drafts ", draft_count=str(draft_count), )) inner = "(<> " + " ".join(parts) + ")" if parts else "" return sx_call("blog-action-buttons-wrapper", inner=SxExpr(inner) if inner else None, ) def _tag_groups_filter_sx(ctx: dict) -> str: """Tag group filter bar as sx.""" tag_groups = ctx.get("tag_groups") or [] selected_groups = ctx.get("selected_groups") or () selected_tags = ctx.get("selected_tags") or () hx_select = ctx.get("hx_select_search", "#main-panel") is_any = len(selected_groups) == 0 and len(selected_tags) == 0 any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" li_parts = [sx_call("blog-filter-any-topic", cls=any_cls, hx_select=hx_select)] for group in tag_groups: g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "") g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image") g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour") g_count = getattr(group, "post_count", 0) if hasattr(group, "post_count") else group.get("post_count", 0) if g_count <= 0 and g_slug not in selected_groups: continue is_on = g_slug in selected_groups cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" if g_fi: icon = sx_call("blog-filter-group-icon-image", src=g_fi, name=g_name) else: style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;" icon = sx_call("blog-filter-group-icon-color", style=style, initial=g_name[:1]) li_parts.append(sx_call("blog-filter-group-li", cls=cls, hx_get=f"?group={g_slug}&page=1", hx_select=hx_select, icon=SxExpr(icon), name=g_name, count=str(g_count), )) items = "(<> " + " ".join(li_parts) + ")" return sx_call("blog-filter-nav", items=SxExpr(items)) def _authors_filter_sx(ctx: dict) -> str: """Author filter bar as sx.""" authors = ctx.get("authors") or [] selected_authors = ctx.get("selected_authors") or () hx_select = ctx.get("hx_select_search", "#main-panel") is_any = len(selected_authors) == 0 any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" li_parts = [sx_call("blog-filter-any-author", cls=any_cls, hx_select=hx_select)] for author in authors: a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "") a_name = getattr(author, "name", "") if hasattr(author, "name") else author.get("name", "") a_img = getattr(author, "profile_image", None) if hasattr(author, "profile_image") else author.get("profile_image") a_count = getattr(author, "published_post_count", 0) if hasattr(author, "published_post_count") else author.get("published_post_count", 0) is_on = a_slug in selected_authors cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" icon_sx = None if a_img: icon_sx = sx_call("blog-filter-author-icon", src=a_img, name=a_name) li_parts.append(sx_call("blog-filter-author-li", cls=cls, hx_get=f"?author={a_slug}&page=1", hx_select=hx_select, icon=SxExpr(icon_sx) if icon_sx else None, name=a_name, count=str(a_count), )) items = "(<> " + " ".join(li_parts) + ")" return sx_call("blog-filter-nav", items=SxExpr(items)) def _tag_groups_filter_summary_sx(ctx: dict) -> str: """Mobile filter summary for tag groups (sx).""" selected_groups = ctx.get("selected_groups") or () tag_groups = ctx.get("tag_groups") or [] if not selected_groups: return "" names = [] for g in tag_groups: g_slug = getattr(g, "slug", "") if hasattr(g, "slug") else g.get("slug", "") g_name = getattr(g, "name", "") if hasattr(g, "name") else g.get("name", "") if g_slug in selected_groups: names.append(g_name) if not names: return "" return sx_call("blog-filter-summary", text=", ".join(names)) def _authors_filter_summary_sx(ctx: dict) -> str: """Mobile filter summary for authors (sx).""" selected_authors = ctx.get("selected_authors") or () authors = ctx.get("authors") or [] if not selected_authors: return "" names = [] for a in authors: a_slug = getattr(a, "slug", "") if hasattr(a, "slug") else a.get("slug", "") a_name = getattr(a, "name", "") if hasattr(a, "name") else a.get("name", "") if a_slug in selected_authors: names.append(a_name) if not names: return "" return sx_call("blog-filter-summary", text=", ".join(names)) # --------------------------------------------------------------------------- # Post detail main panel # --------------------------------------------------------------------------- def _post_main_panel_sx(ctx: dict) -> str: """Post/page article content.""" from quart import g, url_for as qurl post = ctx.get("post") or {} slug = post.get("slug", "") user = getattr(g, "user", None) rights = ctx.get("rights") or {} is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) hx_select = ctx.get("hx_select_search", "#main-panel") # Draft indicator draft_sx = "" if post.get("status") == "draft": edit_sx = "" if is_admin or (user and post.get("user_id") == getattr(user, "id", None)): edit_href = qurl("blog.post.admin.defpage_post_edit", slug=slug) edit_sx = sx_call("blog-detail-edit-link", href=edit_href, hx_select=hx_select, ) draft_sx = sx_call("blog-detail-draft", publish_requested=post.get("publish_requested"), edit=SxExpr(edit_sx) if edit_sx else None, ) # Blog post chrome (not for pages) chrome_sx = "" if not post.get("is_page"): like_sx = "" if user: liked = post.get("is_liked", False) like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") like_sx = sx_call("blog-detail-like", like_url=like_url, hx_headers=f'{{"X-CSRFToken": "{_ctx_csrf(ctx)}"}}', heart="\u2764\ufe0f" if liked else "\U0001f90d", ) excerpt_sx = "" if post.get("custom_excerpt"): excerpt_sx = sx_call("blog-detail-excerpt", excerpt=post["custom_excerpt"], ) at_bar = _at_bar_sx(post, ctx) chrome_sx = sx_call("blog-detail-chrome", like=SxExpr(like_sx) if like_sx else None, excerpt=SxExpr(excerpt_sx) if excerpt_sx else None, at_bar=SxExpr(at_bar) if at_bar else None, ) fi = post.get("feature_image") html_content = post.get("html", "") sx_content = post.get("sx_content", "") return sx_call("blog-detail-main", draft=SxExpr(draft_sx) if draft_sx else None, chrome=SxExpr(chrome_sx) if chrome_sx else None, feature_image=fi, html_content=html_content, sx_content=SxExpr(sx_content) if sx_content else None, ) def _post_meta_sx(ctx: dict) -> str: """Post SEO meta tags as sx (auto-hoisted to by sx.js).""" post = ctx.get("post") or {} base_title = ctx.get("base_title", "") is_public = post.get("visibility") == "public" is_published = post.get("status") == "published" email_only = post.get("email_only", False) robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow" # Description desc = (post.get("meta_description") or post.get("og_description") or post.get("twitter_description") or post.get("custom_excerpt") or post.get("excerpt") or "") if not desc and post.get("html"): import re desc = re.sub(r'<[^>]+>', '', post["html"]) desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160] # Image image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "") # Canonical from quart import request as req canonical = post.get("canonical_url") or (req.url if req else "") post_title = post.get("meta_title") or post.get("title") or "" page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title og_title = post.get("og_title") or page_title tw_title = post.get("twitter_title") or page_title is_article = not post.get("is_page") return sx_call("blog-meta", robots=robots, page_title=page_title, desc=desc, canonical=canonical, og_type="article" if is_article else "website", og_title=og_title, image=image, twitter_card="summary_large_image" if image else "summary", twitter_title=tw_title, ) # --------------------------------------------------------------------------- # Home page (Ghost "home" page) # --------------------------------------------------------------------------- def _home_main_panel_sx(ctx: dict) -> str: """Home page content — renders the Ghost page HTML or sx_content.""" post = ctx.get("post") or {} html = post.get("html", "") sx_content = post.get("sx_content", "") return sx_call("blog-home-main", html_content=html, sx_content=SxExpr(sx_content) if sx_content else None) # --------------------------------------------------------------------------- # Post admin - empty main panel # --------------------------------------------------------------------------- def _post_admin_main_panel_sx(ctx: dict) -> str: return '(div :class "pb-8")' # --------------------------------------------------------------------------- # Settings main panels # --------------------------------------------------------------------------- def _settings_main_panel_sx(ctx: dict) -> str: return '(div :class "max-w-2xl mx-auto px-4 py-6")' def _cache_main_panel_sx(ctx: dict) -> str: from quart import url_for as qurl csrf = _ctx_csrf(ctx) clear_url = qurl("settings.cache_clear") return sx_call("blog-cache-panel", clear_url=clear_url, csrf=csrf) # --------------------------------------------------------------------------- # Snippets main panel # --------------------------------------------------------------------------- def _snippets_main_panel_sx(ctx: dict) -> str: sl = _snippets_list_sx(ctx) return sx_call("blog-snippets-panel", list=SxExpr(sl)) def _snippets_list_sx(ctx: dict) -> str: """Snippets list with visibility badges and delete buttons.""" from quart import url_for as qurl, g snippets = ctx.get("snippets") or [] is_admin = ctx.get("is_admin", False) csrf = _ctx_csrf(ctx) user = getattr(g, "user", None) user_id = getattr(user, "id", None) if not snippets: return sx_call("empty-state", icon="fa fa-puzzle-piece", message="No snippets yet. Create one from the blog editor.") badge_colours = { "private": "bg-stone-200 text-stone-700", "shared": "bg-blue-100 text-blue-700", "admin": "bg-amber-100 text-amber-700", } row_parts = [] for s in snippets: s_id = getattr(s, "id", None) or s.get("id") s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "") s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id") s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private") owner = "You" if s_uid == user_id else f"User #{s_uid}" badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700") extra = "" if is_admin: patch_url = qurl("snippets.patch_visibility", snippet_id=s_id) opts = "" for v in ["private", "shared", "admin"]: opts += sx_call("blog-snippet-option", value=v, selected=(s_vis == v), label=v, ) extra += sx_call("blog-snippet-visibility-select", patch_url=patch_url, hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', options=SxExpr("(<> " + opts + ")") if opts else None, cls="text-sm border border-stone-300 rounded px-2 py-1", ) if s_uid == user_id or is_admin: del_url = qurl("snippets.delete_snippet", snippet_id=s_id) extra += sx_call("delete-btn", url=del_url, trigger_target="#snippets-list", title="Delete snippet?", text=f'Delete \u201c{s_name}\u201d?', sx_headers=f'{{"X-CSRFToken": "{csrf}"}}', cls="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0", ) row_parts.append(sx_call("blog-snippet-row", name=s_name, owner=owner, badge_cls=badge_cls, visibility=s_vis, extra=SxExpr("(<> " + extra + ")") if extra else None, )) rows = "(<> " + " ".join(row_parts) + ")" return sx_call("blog-snippets-list", rows=SxExpr(rows)) # --------------------------------------------------------------------------- # Menu items main panel # --------------------------------------------------------------------------- def _menu_items_main_panel_sx(ctx: dict) -> str: from quart import url_for as qurl new_url = qurl("menu_items.new_menu_item") ml = _menu_items_list_sx(ctx) return sx_call("blog-menu-items-panel", new_url=new_url, list=SxExpr(ml)) def _menu_items_list_sx(ctx: dict) -> str: from quart import url_for as qurl menu_items = ctx.get("menu_items") or [] csrf = _ctx_csrf(ctx) if not menu_items: return sx_call("empty-state", icon="fa fa-inbox", message="No menu items yet. Add one to get started!") row_parts = [] for item in menu_items: i_id = getattr(item, "id", None) or item.get("id") label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "") slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "") fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image") sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0) edit_url = qurl("menu_items.edit_menu_item", item_id=i_id) del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id) img_sx = sx_call("img-or-placeholder", src=fi, alt=label, size_cls="w-12 h-12 rounded-full object-cover flex-shrink-0") row_parts.append(sx_call("blog-menu-item-row", img=SxExpr(img_sx), label=label, slug=slug, sort_order=str(sort), edit_url=edit_url, delete_url=del_url, confirm_text=f"Remove {label} from the menu?", hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', )) rows = "(<> " + " ".join(row_parts) + ")" return sx_call("blog-menu-items-list", rows=SxExpr(rows)) # --------------------------------------------------------------------------- # Tag groups main panel # --------------------------------------------------------------------------- def _tag_groups_main_panel_sx(ctx: dict) -> str: from quart import url_for as qurl groups = ctx.get("groups") or [] unassigned_tags = ctx.get("unassigned_tags") or [] csrf = _ctx_csrf(ctx) create_url = qurl("blog.tag_groups_admin.create") form_sx = sx_call("blog-tag-groups-create-form", create_url=create_url, csrf=csrf, ) # Groups list groups_html = "" if groups: li_parts = [] for group in groups: g_id = getattr(group, "id", None) or group.get("id") g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "") g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image") g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour") g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0) edit_href = qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id) if g_fi: icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name) else: style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;" icon = sx_call("blog-tag-group-icon-color", style=style, initial=g_name[:1]) li_parts.append(sx_call("blog-tag-group-li", icon=SxExpr(icon), edit_href=edit_href, name=g_name, slug=g_slug, sort_order=str(g_sort), )) groups_sx = sx_call("blog-tag-groups-list", items=SxExpr("(<> " + " ".join(li_parts) + ")")) else: groups_sx = sx_call("empty-state", message="No tag groups yet.", cls="text-stone-500 text-sm") # Unassigned tags unassigned_sx = "" if unassigned_tags: tag_spans = [] for tag in unassigned_tags: t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") tag_spans.append(sx_call("blog-unassigned-tag", name=t_name)) unassigned_sx = sx_call("blog-unassigned-tags", heading=f"Unassigned Tags ({len(unassigned_tags)})", spans=SxExpr("(<> " + " ".join(tag_spans) + ")"), ) return sx_call("blog-tag-groups-main", form=SxExpr(form_sx), groups=SxExpr(groups_sx), unassigned=SxExpr(unassigned_sx) if unassigned_sx else None, ) def _tag_groups_edit_main_panel_sx(ctx: dict) -> str: from quart import url_for as qurl group = ctx.get("group") all_tags = ctx.get("all_tags") or [] assigned_tag_ids = ctx.get("assigned_tag_ids") or set() csrf = _ctx_csrf(ctx) g_id = getattr(group, "id", None) or group.get("id") if group else None g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "") g_colour = getattr(group, "colour", "") if hasattr(group, "colour") else (group.get("colour", "") if group else "") g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else (group.get("sort_order", 0) if group else 0) g_fi = getattr(group, "feature_image", "") if hasattr(group, "feature_image") else (group.get("feature_image", "") if group else "") save_url = qurl("blog.tag_groups_admin.save", id=g_id) del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id) # Tag checkboxes tag_items = [] for tag in all_tags: t_id = getattr(tag, "id", None) or tag.get("id") t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image") checked = t_id in assigned_tag_ids img = sx_call("blog-tag-checkbox-image", src=t_fi) if t_fi else "" tag_items.append(sx_call("blog-tag-checkbox", tag_id=str(t_id), checked=checked, img=SxExpr(img) if img else None, name=t_name, )) edit_form = sx_call("blog-tag-group-edit-form", save_url=save_url, csrf=csrf, name=g_name, colour=g_colour or "", sort_order=str(g_sort), feature_image=g_fi or "", tags=SxExpr("(<> " + " ".join(tag_items) + ")"), ) del_form = sx_call("blog-tag-group-delete-form", delete_url=del_url, csrf=csrf, ) return sx_call("blog-tag-group-edit-main", edit_form=SxExpr(edit_form), delete_form=SxExpr(del_form), ) # --------------------------------------------------------------------------- # New post/page main panel — left as render_template (uses Koenig editor JS) # Post edit main panel — left as render_template (uses Koenig editor JS) # Post settings main panel — left as render_template (complex form macros) # Post entries main panel — left as render_template (calendar browser lazy-loads) # Post data main panel — left as render_template (uses ORM introspection macros) # --------------------------------------------------------------------------- # =========================================================================== # PUBLIC API — called from route handlers # =========================================================================== # ---- Home page ---- async def render_home_page(ctx: dict) -> str: root_hdr = root_header_sx(ctx) post_hdr = _post_header_sx(ctx) header_rows = "(<> " + root_hdr + " " + post_hdr + ")" content = _home_main_panel_sx(ctx) meta = _post_meta_sx(ctx) menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx)) return full_page_sx(ctx, header_rows=header_rows, content=content, meta=meta, menu=menu) async def render_home_oob(ctx: dict) -> str: root_hdr = root_header_sx(ctx) post_hdr = _post_header_sx(ctx) rows = "(<> " + root_hdr + " " + post_hdr + ")" header_oob = _oob_header_sx("root-header-child", "post-header-child", rows) content = _home_main_panel_sx(ctx) return oob_page_sx(oobs=header_oob, content=content) # ---- Blog index ---- async def render_blog_page(ctx: dict) -> str: root_hdr = root_header_sx(ctx) blog_hdr = _blog_header_sx(ctx) header_rows = "(<> " + root_hdr + " " + blog_hdr + ")" content = _blog_main_panel_sx(ctx) aside = _blog_aside_sx(ctx) filter_sx = _blog_filter_sx(ctx) return full_page_sx(ctx, header_rows=header_rows, content=content, aside=aside, filter=filter_sx) async def render_blog_oob(ctx: dict) -> str: root_hdr = root_header_sx(ctx) blog_hdr = _blog_header_sx(ctx) rows = "(<> " + root_hdr + " " + blog_hdr + ")" header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows) content = _blog_main_panel_sx(ctx) aside = _blog_aside_sx(ctx) filter_sx = _blog_filter_sx(ctx) return oob_page_sx(oobs=header_oob, content=content, aside=aside, filter=filter_sx) async def render_blog_cards(ctx: dict) -> str: """Pagination-only response (page > 1) — sx wire format.""" return _blog_cards_sx(ctx) async def render_blog_page_cards(ctx: dict) -> str: """Page cards pagination response.""" return _page_cards_sx(ctx) # ---- New post/page editor panel ---- def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str: """Build the WYSIWYG editor panel HTML (replaces _main_panel.html template). This is synchronous — it just assembles an HTML string from the current request context (url_for, CSRF token, asset URLs, config). """ import os from quart import url_for as qurl, current_app from shared.browser.app.csrf import generate_csrf_token from markupsafe import escape as esc csrf = generate_csrf_token() asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") editor_css = asset_url_fn("scripts/editor.css") editor_js = asset_url_fn("scripts/editor.js") sx_editor_js = asset_url_fn("scripts/sx-editor.js") upload_image_url = qurl("blog.editor_api.upload_image") upload_media_url = qurl("blog.editor_api.upload_media") upload_file_url = qurl("blog.editor_api.upload_file") oembed_url = qurl("blog.editor_api.oembed_proxy") snippets_url = qurl("blog.editor_api.list_snippets") unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "") title_placeholder = "Page title..." if is_page else "Post title..." create_label = "Create Page" if is_page else "Create Post" parts: list[str] = [] # Error banner if save_error: parts.append(sx_call("blog-editor-error", error=str(save_error))) # Form structure form_html = sx_call("blog-editor-form", csrf=csrf, title_placeholder=title_placeholder, create_label=create_label, ) parts.append(form_html) # Editor CSS + inline styles + sx editor styles parts.append(sx_call("blog-editor-styles", css_href=editor_css)) parts.append(sx_call("sx-editor-styles")) # Editor JS + init script init_js = ( "console.log('[EDITOR-DEBUG] init script running');\n" "(function() {\n" " console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n" " // Font size overrides disabled — caused global font shrinking\n" " // function applyEditorFontSize() {\n" " // document.documentElement.style.fontSize = '62.5%';\n" " // document.body.style.fontSize = '1.6rem';\n" " // }\n" " // applyEditorFontSize();\n" "\n" " function init() {\n" " var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n" f" var uploadUrl = '{upload_image_url}';\n" " var uploadUrls = {\n" " image: uploadUrl,\n" f" media: '{upload_media_url}',\n" f" file: '{upload_file_url}',\n" " };\n" "\n" " var fileInput = document.getElementById('feature-image-file');\n" " var addBtn = document.getElementById('feature-image-add-btn');\n" " var deleteBtn = document.getElementById('feature-image-delete-btn');\n" " var preview = document.getElementById('feature-image-preview');\n" " var emptyState = document.getElementById('feature-image-empty');\n" " var filledState = document.getElementById('feature-image-filled');\n" " var hiddenUrl = document.getElementById('feature-image-input');\n" " var hiddenCaption = document.getElementById('feature-image-caption-input');\n" " var captionInput = document.getElementById('feature-image-caption');\n" " var uploading = document.getElementById('feature-image-uploading');\n" "\n" " function showFilled(url) {\n" " preview.src = url;\n" " hiddenUrl.value = url;\n" " emptyState.classList.add('hidden');\n" " filledState.classList.remove('hidden');\n" " uploading.classList.add('hidden');\n" " }\n" "\n" " function showEmpty() {\n" " preview.src = '';\n" " hiddenUrl.value = '';\n" " hiddenCaption.value = '';\n" " captionInput.value = '';\n" " emptyState.classList.remove('hidden');\n" " filledState.classList.add('hidden');\n" " uploading.classList.add('hidden');\n" " }\n" "\n" " function uploadFile(file) {\n" " emptyState.classList.add('hidden');\n" " uploading.classList.remove('hidden');\n" " var fd = new FormData();\n" " fd.append('file', file);\n" " fetch(uploadUrl, {\n" " method: 'POST',\n" " body: fd,\n" " headers: { 'X-CSRFToken': csrfToken },\n" " })\n" " .then(function(r) {\n" " if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n" " return r.json();\n" " })\n" " .then(function(data) {\n" " var url = data.images && data.images[0] && data.images[0].url;\n" " if (url) showFilled(url);\n" " else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n" " })\n" " .catch(function(e) {\n" " showEmpty();\n" " alert(e.message);\n" " });\n" " }\n" "\n" " addBtn.addEventListener('click', function() { fileInput.click(); });\n" " preview.addEventListener('click', function() { fileInput.click(); });\n" " deleteBtn.addEventListener('click', function(e) {\n" " e.stopPropagation();\n" " showEmpty();\n" " });\n" " fileInput.addEventListener('change', function() {\n" " if (fileInput.files && fileInput.files[0]) {\n" " uploadFile(fileInput.files[0]);\n" " fileInput.value = '';\n" " }\n" " });\n" " captionInput.addEventListener('input', function() {\n" " hiddenCaption.value = captionInput.value;\n" " });\n" "\n" " var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n" " function autoResize() {\n" " excerpt.style.height = 'auto';\n" " excerpt.style.height = excerpt.scrollHeight + 'px';\n" " }\n" " excerpt.addEventListener('input', autoResize);\n" " autoResize();\n" "\n" " window.mountEditor('lexical-editor', {\n" " initialJson: null,\n" " csrfToken: csrfToken,\n" " uploadUrls: uploadUrls,\n" f" oembedUrl: '{oembed_url}',\n" f" unsplashApiKey: '{unsplash_key}',\n" f" snippetsUrl: '{snippets_url}',\n" " });\n" "\n" " if (typeof SxEditor !== 'undefined') {\n" " SxEditor.mount('sx-editor', {\n" " initialSx: window.__SX_INITIAL__ || null,\n" " csrfToken: csrfToken,\n" " uploadUrls: uploadUrls,\n" f" oembedUrl: '{oembed_url}',\n" " onChange: function(sx) {\n" " document.getElementById('sx-content-input').value = sx;\n" " }\n" " });\n" " }\n" "\n" " document.addEventListener('keydown', function(e) {\n" " if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n" " e.preventDefault();\n" " document.getElementById('post-new-form').requestSubmit();\n" " }\n" " });\n" " }\n" "\n" " if (typeof window.mountEditor === 'function') {\n" " init();\n" " } else {\n" " var _t = setInterval(function() {\n" " if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n" " }, 50);\n" " }\n" "})();\n" ) parts.append(sx_call("blog-editor-scripts", js_src=editor_js, sx_editor_js_src=sx_editor_js, init_js=init_js)) return "(<> " + " ".join(parts) + ")" if parts else "" # ---- New post/page ---- async def render_new_post_page(ctx: dict) -> str: root_hdr = root_header_sx(ctx) blog_hdr = _blog_header_sx(ctx) header_rows = "(<> " + root_hdr + " " + blog_hdr + ")" content = ctx.get("editor_html", "") return full_page_sx(ctx, header_rows=header_rows, content=content) # ---- Post detail ---- async def render_post_page(ctx: dict) -> str: root_hdr = root_header_sx(ctx) post_hdr = _post_header_sx(ctx) header_rows = "(<> " + root_hdr + " " + post_hdr + ")" content = _post_main_panel_sx(ctx) meta = _post_meta_sx(ctx) menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx)) return full_page_sx(ctx, header_rows=header_rows, content=content, meta=meta, menu=menu) async def render_post_oob(ctx: dict) -> str: root_hdr = root_header_sx(ctx) # non-OOB (nested inside root-header-child) post_hdr = _post_header_sx(ctx) rows = "(<> " + root_hdr + " " + post_hdr + ")" post_oob = _oob_header_sx("root-header-child", "post-header-child", rows) content = _post_main_panel_sx(ctx) menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx)) oobs = post_oob return oob_page_sx(oobs=oobs, content=content, menu=menu) # ---- Post admin ---- # =========================================================================== def _post_data_content_sx(ctx: dict) -> str: """Build post data inspector panel natively (replaces _types/post_data/_main_panel.html).""" from markupsafe import escape as esc from quart import g original_post = getattr(g, "post_data", {}).get("original_post") if original_post is None: return _raw_html_sx('
No post data available.
') tablename = getattr(original_post, "__tablename__", "?") def _render_scalar_table(obj): rows = [] for col in obj.__mapper__.columns: key = col.key if key == "_sa_instance_state": continue val = getattr(obj, key, None) if val is None: val_html = '\u2014' elif hasattr(val, "isoformat"): val_html = f'
{esc(val.isoformat())}
' elif isinstance(val, str): val_html = f'
{esc(val)}
' else: val_html = f'
{esc(str(val))}
' rows.append( f'' f'{esc(key)}' f'{val_html}' ) return ( '
' '' '' '' '' '' + "".join(rows) + '
FieldValue
' ) def _render_model(obj, depth=0, max_depth=2): parts = [_render_scalar_table(obj)] rel_parts = [] for rel in obj.__mapper__.relationships: rel_name = rel.key loaded = rel_name in obj.__dict__ value = getattr(obj, rel_name, None) if loaded else None cardinality = "many" if rel.uselist else "one" cls_name = rel.mapper.class_.__name__ loaded_label = "" if loaded else " \u2022 not loaded" inner = "" if value is None: inner = '\u2014' elif rel.uselist: items = list(value) if value else [] inner = f'
{len(items)} item{"" if len(items) == 1 else "s"}
' if items and depth < max_depth: sub_rows = [] for i, it in enumerate(items, 1): ident_parts = [] for k in ("id", "ghost_id", "uuid", "slug", "name", "title"): if k in it.__mapper__.c: v = getattr(it, k, "") ident_parts.append(f"{k}={v}") summary = " \u2022 ".join(ident_parts) if ident_parts else str(it) child_html = "" if depth < max_depth: child_html = f'
{_render_model(it, depth + 1, max_depth)}
' else: child_html = '
\u2026max depth reached\u2026
' sub_rows.append( f'' f'{i}' f'
{esc(summary)}
{child_html}' ) inner += ( '
' '' '' '' + "".join(sub_rows) + '
#Summary
' ) else: child = value ident_parts = [] for k in ("id", "ghost_id", "uuid", "slug", "name", "title"): if k in child.__mapper__.c: v = getattr(child, k, "") ident_parts.append(f"{k}={v}") summary = " \u2022 ".join(ident_parts) if ident_parts else str(child) inner = f'
{esc(summary)}
' if depth < max_depth: inner += f'
{_render_model(child, depth + 1, max_depth)}
' else: inner += '
\u2026max depth reached\u2026
' rel_parts.append( f'
' f'
' f'Relationship: {esc(rel_name)}' f' {cardinality} \u2192 {esc(cls_name)}{loaded_label}
' f'
{inner}
' ) if rel_parts: parts.append('
' + "".join(rel_parts) + '
') return '
' + "".join(parts) + '
' html = ( f'
' f'
Model: Post \u2022 Table: {esc(tablename)}
' f'{_render_model(original_post, 0, 2)}
' ) return _raw_html_sx(html) # =========================================================================== def _preview_main_panel_sx(ctx: dict) -> str: """Build the preview panel with 4 expandable sections.""" sections: list[str] = [] # 1. Prettified SX source sx_pretty = ctx.get("sx_pretty", "") if sx_pretty: sections.append(sx_call("blog-preview-section", title="S-Expression Source", content=SxExpr(sx_pretty), )) # 2. Prettified Lexical JSON json_pretty = ctx.get("json_pretty", "") if json_pretty: sections.append(sx_call("blog-preview-section", title="Lexical JSON", content=SxExpr(json_pretty), )) # 3. SX rendered preview sx_rendered = ctx.get("sx_rendered", "") if sx_rendered: rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(sx_rendered)}))' sections.append(sx_call("blog-preview-section", title="SX Rendered", content=SxExpr(rendered_sx), )) # 4. Lexical rendered preview lex_rendered = ctx.get("lex_rendered", "") if lex_rendered: rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(lex_rendered)}))' sections.append(sx_call("blog-preview-section", title="Lexical Rendered", content=SxExpr(rendered_sx), )) if not sections: return '(div :class "p-8 text-stone-500" "No content to preview.")' inner = " ".join(sections) return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})")) # =========================================================================== def _post_entries_content_sx(ctx: dict) -> str: """Build post entries panel natively (replaces _types/post_entries/_main_panel.html).""" from quart import g, url_for as qurl from shared.utils import host_url all_calendars = ctx.get("all_calendars", []) associated_entry_ids = ctx.get("associated_entry_ids", set()) post_slug = g.post_data["post"]["slug"] # Associated entries list (reuse existing render function) assoc_html = render_associated_entries(all_calendars, associated_entry_ids, post_slug) # Calendar browser cal_items: list[str] = [] for cal in all_calendars: cal_post = getattr(cal, "post", None) cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None cal_title = escape(getattr(cal_post, "title", "")) if cal_post else "" cal_name = escape(getattr(cal, "name", "")) cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id)) img_html = ( f'{cal_title}' if cal_fi else '
' ) cal_items.append( f'
' f'' f'{img_html}' f'
' f'
{cal_name}
' f'
{cal_title}
' f'
' f'
' f'
Loading calendar...
' f'
' ) if cal_items: browser_html = ( '

Browse Calendars

' + "".join(cal_items) + '
' ) else: browser_html = '

Browse Calendars

No calendars found.
' # assoc_html is sx (from render_associated_entries); browser is raw HTML # Wrap the whole thing: open div as raw, then associated entries (sx), then browser (raw), close div return ( _raw_html_sx('
') + assoc_html + _raw_html_sx(browser_html + '
') ) # ---- Calendar view (for entries browser) ---- def render_calendar_view( calendar, year, month, month_name, weekday_names, weeks, prev_month, prev_month_year, next_month, next_month_year, prev_year, next_year, month_entries, associated_entry_ids, post_slug: str, ) -> str: """Build calendar month grid HTML (replaces _types/post/admin/_calendar_view.html).""" from quart import url_for as qurl from shared.browser.app.csrf import generate_csrf_token from shared.utils import host_url esc = escape csrf = generate_csrf_token() cal_id = calendar.id def cal_url(y, m): return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m))) cur_url = cal_url(year, month) toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid))) # Navigation header nav = ( f'
' f'
' ) # Weekday header wd_cells = "".join(f'
{esc(wd)}
' for wd in weekday_names) wd_row = f'' # Grid cells cells: list[str] = [] for week in weeks: for day in week: extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else "" day_date = day.date entry_btns: list[str] = [] for e in month_entries: e_start = getattr(e, "start_at", None) if not e_start or e_start.date() != day_date: continue e_id = getattr(e, "id", None) e_name = esc(getattr(e, "name", "")) t_url = toggle_url_fn(e_id) hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}' if e_id in associated_entry_ids: entry_btns.append( f'
' f'{e_name}' f'
' ) else: entry_btns.append( f'' ) entries_html = '
' + "".join(entry_btns) + '
' if entry_btns else '' cells.append( f'
' f'
{day_date.day}
{entries_html}
' ) grid = f'
{"".join(cells)}
' html = ( f'
' f'{nav}' f'
{wd_row}{grid}
' f'
' ) return _raw_html_sx(html) # ---- Post edit ---- def _raw_html_sx(html: str) -> str: """Wrap raw HTML in (raw! "...") so it's valid inside sx source.""" if not html: return "" return "(raw! " + sx_serialize(html) + ")" def _post_edit_content_sx(ctx: dict) -> str: """Build WYSIWYG editor panel natively (replaces _types/post_edit/_main_panel.html).""" from quart import url_for as qurl, current_app, g, request as qrequest from shared.browser.app.csrf import generate_csrf_token esc = escape ghost_post = ctx.get("ghost_post", {}) or {} save_success = ctx.get("save_success", False) save_error = ctx.get("save_error", "") newsletters = ctx.get("newsletters", []) csrf = generate_csrf_token() asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") editor_css = asset_url_fn("scripts/editor.css") editor_js = asset_url_fn("scripts/editor.js") sx_editor_js = asset_url_fn("scripts/sx-editor.js") upload_image_url = qurl("blog.editor_api.upload_image") upload_media_url = qurl("blog.editor_api.upload_media") upload_file_url = qurl("blog.editor_api.upload_file") oembed_url = qurl("blog.editor_api.oembed_proxy") snippets_url = qurl("blog.editor_api.list_snippets") unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "") post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {} is_page = post.get("is_page", False) feature_image = ghost_post.get("feature_image") or "" feature_image_caption = ghost_post.get("feature_image_caption") or "" title_val = esc(ghost_post.get("title") or "") excerpt_val = esc(ghost_post.get("custom_excerpt") or "") updated_at = esc(ghost_post.get("updated_at") or "") status = ghost_post.get("status") or "draft" lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' sx_content = ghost_post.get("sx_content") or "" already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status")) # For ORM objects the email may be an object email_obj = ghost_post.get("email") if email_obj and not isinstance(email_obj, dict): already_emailed = bool(getattr(email_obj, "status", None)) parts: list[str] = [] # Error banner if save_error: parts.append( f'
' f'Save failed: {esc(save_error)}
' ) # Hidden inputs fi_hidden = f' hidden' if not feature_image else '' fi_visible = f' hidden' if feature_image else '' title_placeholder = "Page title..." if is_page else "Post title..." form_parts: list[str] = [] form_parts.append(f'') form_parts.append(f'') form_parts.append('') form_parts.append(f'') form_parts.append(f'') form_parts.append(f'') # Feature image section form_parts.append( f'
' f'
' f'' f'
' f'
' f'' f'' f'' f'
' f'' f'' f'
' ) # Title form_parts.append( f'' ) # Excerpt form_parts.append( f'' ) # Editor tabs: SX (primary) and Koenig (legacy) has_sx = bool(sx_content) sx_active = 'text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent' sx_inactive = 'text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600' form_parts.append( '
' f'' f'' '
' ) # SX editor mount point form_parts.append(f'
') # Koenig editor mount point form_parts.append(f'
') # Initial lexical JSON form_parts.append(f'') # Status + publish footer draft_sel = ' selected' if status == 'draft' else '' pub_sel = ' selected' if status == 'published' else '' mode_hidden = ' hidden' if status != 'published' else '' mode_disabled = ' opacity-50 pointer-events-none' if already_emailed else '' mode_dis_attr = ' disabled' if already_emailed else '' nl_options = '' for nl in newsletters: nl_slug = esc(getattr(nl, "slug", "")) nl_name = esc(getattr(nl, "name", "")) nl_options += f'' footer_extra = '' if save_success: footer_extra += ' Saved.' publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None if publish_requested: footer_extra += ' Publish requested \u2014 an admin will review.' if post.get("publish_requested"): footer_extra += ' Publish requested' if already_emailed: nl_name = "" newsletter = ghost_post.get("newsletter") if newsletter: nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "") suffix = f" to {esc(nl_name)}" if nl_name else "" footer_extra += f' Emailed{suffix}' form_parts.append( f'
' f'' f'' f'' f'' f'{footer_extra}
' ) form_html = '
' + "".join(form_parts) + '
' parts.append(form_html) # Publish-mode show/hide JS already_emailed_js = 'true' if already_emailed else 'false' parts.append( '' ) # Editor CSS + styles + SX editor styles from shared.sx.helpers import sx_call parts.append(f'') parts.append(sx_call("sx-editor-styles")) parts.append( '' ) # Initial sx content for SX editor sx_initial_escaped = sx_content.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n').replace('\r', '') parts.append(f"") # Editor JS + SX editor JS + init parts.append(f'') parts.append(f'') parts.append( '' ) return _raw_html_sx("".join(parts)) # =========================================================================== def _post_settings_content_sx(ctx: dict) -> str: """Build settings form natively (replaces _types/post_settings/_main_panel.html).""" from quart import g from shared.browser.app.csrf import generate_csrf_token esc = escape ghost_post = ctx.get("ghost_post", {}) or {} save_success = ctx.get("save_success", False) csrf = generate_csrf_token() post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {} is_page = post.get("is_page", False) def field_label(text, field_for=None): for_attr = f' for="{field_for}"' if field_for else '' return f'{esc(text)}' input_cls = ('w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] ' 'bg-white text-stone-700 placeholder:text-stone-300 ' 'focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300') textarea_cls = input_cls + ' resize-y' def text_input(name, value='', placeholder='', input_type='text', maxlength=None): ml = f' maxlength="{maxlength}"' if maxlength else '' return (f'') def textarea_input(name, value='', placeholder='', rows=3, maxlength=None): ml = f' maxlength="{maxlength}"' if maxlength else '' return (f'') def checkbox_input(name, checked=False, label=''): chk = ' checked' if checked else '' return (f'') def section(title, content, is_open=False): open_attr = ' open' if is_open else '' return (f'
' f'{esc(title)}' f'
{content}
') gp = ghost_post # General section slug_placeholder = 'page-slug' if is_page else 'post-slug' pub_at = gp.get("published_at") or "" pub_at_val = pub_at[:16] if pub_at else "" vis = gp.get("visibility") or "public" vis_opts = "".join( f'' for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")] ) general = ( f'
{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}
' f'
{field_label("Published at", "settings-published_at")}' f'
' f'
{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}
' f'
{field_label("Visibility", "settings-visibility")}' f'
' f'
{checkbox_input("email_only", gp.get("email_only"), "Email only")}
' ) # Tags tags = gp.get("tags") or [] if tags: tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags) else: tag_names = "" tags_sec = ( f'
{field_label("Tags (comma-separated)", "settings-tags")}' f'{text_input("tags", tag_names, "news, updates, featured")}' f'

Unknown tags will be created automatically.

' ) # Feature image fi_sec = f'
{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}
' # SEO seo_sec = ( f'
{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}' f'

Recommended: 70 characters. Max: 300.

' f'
{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}' f'

Recommended: 156 characters.

' f'
{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}
' ) # Facebook / OG og_sec = ( f'
{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}
' f'
{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}
' f'
{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}
' ) # Twitter tw_sec = ( f'
{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}
' f'
{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}
' f'
{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}
' ) # Advanced tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs' adv_sec = f'
{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}
' sections = ( section("General", general, is_open=True) + section("Tags", tags_sec) + section("Feature Image", fi_sec) + section("SEO / Meta", seo_sec) + section("Facebook / OpenGraph", og_sec) + section("X / Twitter", tw_sec) + section("Advanced", adv_sec) ) saved_html = 'Saved.' if save_success else '' html = ( f'
' f'' f'' f'
{sections}
' f'
' f'' f'{saved_html}
' ) return _raw_html_sx(html) # =========================================================================== # =========================================================================== # =========================================================================== # =========================================================================== # =========================================================================== # =========================================================================== # =========================================================================== # PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers # =========================================================================== # ---- Like toggle button (delegates to market impl) ---- def render_like_toggle_button(slug: str, liked: bool, like_url: str) -> str: """Render a like toggle button for HTMX POST response.""" from market.sx.sx_components import render_like_toggle_button as _market_like return _market_like(slug, liked, like_url=like_url, item_type="post") # ---- Snippets list ---- def render_snippets_list(snippets, is_admin: bool) -> str: """Render the snippets list fragment for HTMX DELETE/PATCH responses.""" from shared.browser.app.csrf import generate_csrf_token from quart import g ctx = { "snippets": snippets, "is_admin": is_admin, "csrf_token": generate_csrf_token(), } return _snippets_list_sx(ctx) # ---- Menu items list + nav OOB ---- def render_menu_items_list(menu_items) -> str: """Render the menu items list fragment for HTMX responses.""" from shared.browser.app.csrf import generate_csrf_token ctx = { "menu_items": menu_items, "csrf_token": generate_csrf_token(), } return _menu_items_list_sx(ctx) def render_menu_item_form(menu_item=None) -> str: """Render menu item add/edit form (replaces _types/menu_items/_form.html).""" from quart import url_for as qurl from shared.browser.app.csrf import generate_csrf_token csrf = generate_csrf_token() search_url = qurl("menu_items.search_pages_route") is_edit = menu_item is not None if is_edit: action_url = qurl("menu_items.update_menu_item_route", item_id=menu_item.id) action_attr = f'sx-put="{action_url}"' post_id = str(menu_item.container_id) if menu_item.container_id else "" label = getattr(menu_item, "label", "") or "" slug = getattr(menu_item, "slug", "") or "" fi = getattr(menu_item, "feature_image", None) or "" else: action_url = qurl("menu_items.create_menu_item_route") action_attr = f'sx-post="{action_url}"' post_id = "" label = "" slug = "" fi = "" # Build selected page display if post_id: img_html = (f'{label}' if fi else '
') selected = (f'
' f'{img_html}
{label}
' f'
{slug}
') else: selected = '' close_js = "document.getElementById('menu-item-form').innerHTML = ''" title = "Edit Menu Item" if is_edit else "Add Menu Item" html = f''' ''' return html def render_page_search_results(pages, query, page, has_more) -> str: """Render page search results (replaces _types/menu_items/_page_search_results.html).""" from quart import url_for as qurl if not pages and query: return sx_call("page-search-empty", query=query) if not pages: return "" items = [] for post in pages: items.append(sx_call("page-search-item", id=post.id, title=post.title, slug=post.slug, feature_image=post.feature_image or None)) sentinel = "" if has_more: search_url = qurl("menu_items.search_pages_route") sentinel = sx_call("page-search-sentinel", url=search_url, query=query, next_page=page + 1) items_sx = "(<> " + " ".join(items) + ")" return sx_call("page-search-results", items=SxExpr(items_sx), sentinel=SxExpr(sentinel) if sentinel else None) def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str: """Render the OOB nav update for menu items. Produces the same DOM structure as ``_types/menu_items/_nav_oob.html``: a scrolling nav wrapper with ``id="menu-items-nav-wrapper"`` and ``hx-swap-oob="outerHTML"``. """ from quart import request as qrequest if not menu_items: return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper") # Resolve URL helpers from context or fall back to template globals if ctx is None: ctx = {} first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else "" # nav_button style (matches shared/infrastructure/jinja_setup.py) select_colours = ( "[.hover-capable_&]:hover:bg-yellow-300" " aria-selected:bg-stone-500 aria-selected:text-white" " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500" ) nav_button_cls = ( f"justify-center cursor-pointer flex flex-row items-center gap-2" f" rounded bg-stone-200 text-black {select_colours} p-3" ) container_id = "menu-items-container" arrow_cls = f"scrolling-menu-arrow-{container_id}" scroll_hs = ( f"on load or scroll" f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth" f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}" f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end" ) blog_url_fn = ctx.get("blog_url") cart_url_fn = ctx.get("cart_url") app_name = ctx.get("app_name", "") item_parts = [] for item in menu_items: item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "") label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "") fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image") if item_slug == "cart" and cart_url_fn: href = cart_url_fn("/") elif blog_url_fn: href = blog_url_fn(f"/{item_slug}/") else: href = f"/{item_slug}/" selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false" img_sx = sx_call("img-or-placeholder", src=fi, alt=label, size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0") if item_slug != "cart": item_parts.append(sx_call("blog-nav-item-link", href=href, hx_get=f"/{item_slug}/", selected=selected, nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label, )) else: item_parts.append(sx_call("blog-nav-item-plain", href=href, selected=selected, nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label, )) items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "" return sx_call("scroll-nav-wrapper", wrapper_id="menu-items-nav-wrapper", container_id=container_id, arrow_cls=arrow_cls, left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200", scroll_hs=scroll_hs, right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200", items=SxExpr(items_sx) if items_sx else None, oob=True, ) # ---- Features panel ---- def render_features_panel(features: dict, post: dict, sumup_configured: bool, sumup_merchant_code: str, sumup_checkout_prefix: str) -> str: """Render the features panel fragment for HTMX PUT responses.""" from shared.utils import host_url from quart import url_for as qurl slug = post.get("slug", "") features_url = host_url(qurl("blog.post.admin.update_features", slug=slug)) sumup_url = host_url(qurl("blog.post.admin.update_sumup", slug=slug)) hs_trigger = "on change trigger submit on closest
" form_sx = sx_call("blog-features-form", features_url=features_url, calendar_checked=bool(features.get("calendar")), market_checked=bool(features.get("market")), hs_trigger=hs_trigger, ) sumup_sx = "" if features.get("calendar") or features.get("market"): placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..." sumup_sx = sx_call("blog-sumup-form", sumup_url=sumup_url, merchant_code=sumup_merchant_code, placeholder=placeholder, sumup_configured=sumup_configured, checkout_prefix=sumup_checkout_prefix, ) return sx_call("blog-features-panel", form=SxExpr(form_sx), sumup=SxExpr(sumup_sx) if sumup_sx else None, ) # ---- Markets panel ---- def render_markets_panel(markets, post: dict) -> str: """Render the markets panel fragment for HTMX responses.""" from shared.utils import host_url from quart import url_for as qurl slug = post.get("slug", "") create_url = host_url(qurl("blog.post.admin.create_market", slug=slug)) list_sx = "" if markets: li_parts = [] for m in markets: m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "") del_url = host_url(qurl("blog.post.admin.delete_market", slug=slug, market_slug=m_slug)) li_parts.append(sx_call("blog-market-item", name=m_name, slug=m_slug, delete_url=del_url, confirm_text=f"Delete market '{m_name}'?", )) list_sx = sx_call("blog-markets-list", items=SxExpr("(<> " + " ".join(li_parts) + ")")) else: list_sx = sx_call("blog-markets-empty") return sx_call("blog-markets-panel", list=SxExpr(list_sx), create_url=create_url, ) # ---- Associated entries ---- def render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str: """Render the associated entries panel for HTMX POST responses.""" from shared.browser.app.csrf import generate_csrf_token from quart import url_for as qurl from shared.utils import host_url csrf = generate_csrf_token() has_entries = False entry_items: list[str] = [] for calendar in all_calendars: entries = getattr(calendar, "entries", []) or [] cal_name = getattr(calendar, "name", "") cal_post = getattr(calendar, "post", None) cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None cal_title = getattr(cal_post, "title", "") if cal_post else "" for entry in entries: e_id = getattr(entry, "id", None) if e_id not in associated_entry_ids: continue if getattr(entry, "deleted_at", None) is not None: continue has_entries = True e_name = getattr(entry, "name", "") e_start = getattr(entry, "start_at", None) e_end = getattr(entry, "end_at", None) toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id)) img_sx = sx_call("blog-entry-image", src=cal_fi, title=cal_title) date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else "" if e_end: date_str += f" \u2013 {e_end.strftime('%H:%M')}" entry_items.append(sx_call("blog-associated-entry", confirm_text=f"This will remove {e_name} from this post", toggle_url=toggle_url, hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', img=SxExpr(img_sx), name=e_name, date_str=f"{cal_name} \u2022 {date_str}", )) if has_entries: content_sx = sx_call("blog-associated-entries-content", items=SxExpr("(<> " + " ".join(entry_items) + ")"), ) else: content_sx = sx_call("blog-associated-entries-empty") return sx_call("blog-associated-entries-panel", content=SxExpr(content_sx)) # ---- Nav entries OOB ---- def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict | None = None) -> str: """Render the OOB nav entries swap. Produces the ``entries-calendars-nav-wrapper`` OOB element with links to associated entries and calendars. """ if ctx is None: ctx = {} entries_list = [] if associated_entries and hasattr(associated_entries, "entries"): entries_list = associated_entries.entries or [] has_items = bool(entries_list or calendars) if not has_items: return sx_call("blog-nav-entries-empty") events_url_fn = ctx.get("events_url") # nav_button_less_pad style select_colours = ( "[.hover-capable_&]:hover:bg-yellow-300" " aria-selected:bg-stone-500 aria-selected:text-white" " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500" ) nav_cls = ( f"justify-center cursor-pointer flex flex-row items-center gap-2" f" rounded bg-stone-200 text-black {select_colours} p-2" ) post_slug = post.get("slug", "") scroll_hs = ( "on load or scroll" " if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth" " remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow" " else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end" ) item_parts = [] # Entry links for entry in entries_list: e_name = getattr(entry, "name", "") e_start = getattr(entry, "start_at", None) e_end = getattr(entry, "end_at", None) cal_slug = getattr(entry, "calendar_slug", "") if e_start: entry_path = ( f"/{post_slug}/{cal_slug}/" f"{e_start.year}/{e_start.month}/{e_start.day}" f"/entries/{getattr(entry, 'id', '')}/" ) date_str = e_start.strftime("%b %d, %Y at %H:%M") if e_end: date_str += f" \u2013 {e_end.strftime('%H:%M')}" else: entry_path = f"/{post_slug}/{cal_slug}/" date_str = "" href = events_url_fn(entry_path) if events_url_fn else entry_path item_parts.append(sx_call("calendar-entry-nav", href=href, nav_class=nav_cls, name=e_name, date_str=date_str, )) # Calendar links for calendar in (calendars or []): cal_name = getattr(calendar, "name", "") cal_slug = getattr(calendar, "slug", "") cal_path = f"/{post_slug}/{cal_slug}/" href = events_url_fn(cal_path) if events_url_fn else cal_path item_parts.append(sx_call("blog-nav-calendar-item", href=href, nav_cls=nav_cls, name=cal_name, )) items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "" return sx_call("scroll-nav-wrapper", wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container", arrow_cls="entries-nav-arrow", left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200", scroll_hs=scroll_hs, right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200", items=SxExpr(items_sx) if items_sx else None, oob=True, )