'
+ )
+ return _raw_html_sx(html)
+
+
+async def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
+ """Render the associated entries panel."""
+ from shared.browser.app.csrf import generate_csrf_token
+ from quart import url_for as qurl
+
+ 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 = await render_to_sx("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(await render_to_sx("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 = await render_to_sx("blog-associated-entries-content",
+ items=SxExpr("(<> " + " ".join(entry_items) + ")"),
+ )
+ else:
+ content_sx = await render_to_sx("blog-associated-entries-empty")
+
+ return await render_to_sx("blog-associated-entries-panel", content=SxExpr(content_sx))
+
+
+async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
+ """Render the OOB nav entries swap."""
+ 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 await render_to_sx("blog-nav-entries-empty")
+
+ 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 = []
+
+ 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 = ""
+
+ item_parts.append(await render_to_sx("calendar-entry-nav",
+ href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
+ ))
+
+ for calendar in (calendars or []):
+ cal_name = getattr(calendar, "name", "")
+ cal_slug = getattr(calendar, "slug", "")
+ cal_path = f"/{post_slug}/{cal_slug}/"
+
+ item_parts.append(await render_to_sx("blog-nav-calendar-item",
+ href=cal_path, nav_cls=nav_cls, name=cal_name,
+ ))
+
+ items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
+
+ return await render_to_sx("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,
+ )
+
+
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@@ -220,8 +455,7 @@ def register():
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
- from sx.sx_components import render_calendar_view
- html = render_calendar_view(
+ html = _render_calendar_view(
calendar_obj, 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,
@@ -273,11 +507,9 @@ def register():
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
- from sx.sx_components import render_associated_entries, render_nav_entries_oob
-
post = g.post_data["post"]
- admin_list = await render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
- nav_entries_html = await render_nav_entries_oob(associated_entries, calendars, post)
+ admin_list = await _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
+ nav_entries_html = await _render_nav_entries_oob(associated_entries, calendars, post)
return sx_response(admin_list + nav_entries_html)
diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py
deleted file mode 100644
index 2c46b9f..0000000
--- a/blog/sx/sx_components.py
+++ /dev/null
@@ -1,2487 +0,0 @@
-"""
-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, SxExpr
-from shared.sx.helpers import (
- render_to_sx,
- 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)
-# ---------------------------------------------------------------------------
-
-async def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str:
- """Blog header row — empty child of root."""
- return await render_to_sx("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
-# ---------------------------------------------------------------------------
-
-async def _post_header_sx(ctx: dict, *, oob: bool = False) -> str:
- """Build the post-level header row as sx — delegates to shared helper."""
- return await post_header_sx(ctx, oob=oob)
-
-
-# ---------------------------------------------------------------------------
-# Post admin header
-# ---------------------------------------------------------------------------
-
-async 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 await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
-
-
-async 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(
- await post_admin_mobile_nav_sx(ctx, slug, selected),
- await post_mobile_nav_sx(ctx),
- await mobile_root_nav_sx(ctx),
- )
-
-
-# ---------------------------------------------------------------------------
-# Settings header (root-header-child -> root-settings-header-child)
-# ---------------------------------------------------------------------------
-
-async 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 = await render_to_sx("blog-admin-label")
- nav_sx = await _settings_nav_sx(ctx)
-
- return await render_to_sx("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,
- )
-
-
-
-async 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(await render_to_sx("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)
-# ---------------------------------------------------------------------------
-
-async 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 = await render_to_sx("blog-sub-settings-label",
- icon=f"fa fa-{icon}", label=label,
- )
-
- return await render_to_sx("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
-# ---------------------------------------------------------------------------
-
-
-
-async def _blog_sentinel_sx(ctx: dict) -> str:
- """Infinite scroll sentinels as sx calls (for wire format)."""
- 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 await render_to_sx("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 (
- await render_to_sx("sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs)
- + " "
- + await render_to_sx("sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs)
- )
-
-
-async 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(await _blog_card_tile_sx(p, ctx))
- else:
- parts.append(await _blog_card_sx(p, ctx))
- parts.append(await _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
-
-
-async 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 await render_to_sx("blog-card", **kwargs)
-
-
-async 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 await render_to_sx("blog-card-tile", **kwargs)
-
-
-async 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 await render_to_sx("blog-at-bar", tags=tag_data, authors=author_data)
-
-
-
-async 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(await _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(await render_to_sx("sentinel-simple",
- id=f"sentinel-{page_num}-d", next_url=next_url,
- ))
- elif pages:
- parts.append(await render_to_sx("end-of-results"))
- else:
- parts.append(await render_to_sx("blog-no-pages"))
-
- return "(<> " + " ".join(parts) + ")" if parts else ""
-
-
-async 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 await render_to_sx("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,
- )
-
-
-async 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 = await render_to_sx("list-svg")
- tile_svg_sx = await render_to_sx("tile-svg")
-
- return await render_to_sx("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),
- )
-
-
-async 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 await render_to_sx("blog-content-type-tabs",
- posts_href=posts_href, pages_href=pages_href, hx_select=hx_select,
- posts_cls=posts_cls, pages_cls=pages_cls,
- )
-
-
-async 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 = await _content_type_tabs_sx(ctx)
-
- if content_type == "pages":
- cards = await _page_cards_sx(ctx)
- return await render_to_sx("blog-main-panel-pages",
- tabs=SxExpr(tabs), cards=SxExpr(cards),
- )
- else:
- toggle = await _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 = await _blog_cards_sx(ctx)
- return await render_to_sx("blog-main-panel-posts",
- tabs=SxExpr(tabs), toggle=SxExpr(toggle), grid_cls=grid_cls,
- cards=SxExpr(cards),
- )
-
-
-# ---------------------------------------------------------------------------
-# Desktop aside (filter sidebar)
-# ---------------------------------------------------------------------------
-
-async def _blog_aside_sx(ctx: dict) -> str:
- """Desktop aside with search, action buttons, and filters."""
- sd = await search_desktop_sx(ctx)
- ab = await _action_buttons_sx(ctx)
- tgf = await _tag_groups_filter_sx(ctx)
- af = await _authors_filter_sx(ctx)
- return await render_to_sx("blog-aside",
- search=SxExpr(sd), action_buttons=SxExpr(ab),
- tag_groups_filter=SxExpr(tgf), authors_filter=SxExpr(af),
- )
-
-
-async def _blog_filter_sx(ctx: dict) -> str:
- """Mobile filter (details/summary)."""
- # Mobile filter summary tags
- summary_parts = []
- tg_summary = await _tag_groups_filter_summary_sx(ctx)
- au_summary = await _authors_filter_summary_sx(ctx)
- if tg_summary:
- summary_parts.append(tg_summary)
- if au_summary:
- summary_parts.append(au_summary)
-
- search_sx = await search_mobile_sx(ctx)
- if summary_parts:
- filter_content = "(<> " + search_sx + " " + " ".join(summary_parts) + ")"
- else:
- filter_content = search_sx
-
- action_buttons = await _action_buttons_sx(ctx)
- tgf = await _tag_groups_filter_sx(ctx)
- af = await _authors_filter_sx(ctx)
- filter_details = "(<> " + tgf + " " + af + ")"
-
- return await render_to_sx("mobile-filter",
- filter_summary=SxExpr(filter_content),
- action_buttons=SxExpr(action_buttons),
- filter_details=SxExpr(filter_details),
- )
-
-
-async 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(await render_to_sx("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(await render_to_sx("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(await render_to_sx("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(await render_to_sx("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 await render_to_sx("blog-action-buttons-wrapper",
- inner=SxExpr(inner) if inner else None,
- )
-
-
-async 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 = [await render_to_sx("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 = await render_to_sx("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 = await render_to_sx("blog-filter-group-icon-color", style=style, initial=g_name[:1])
-
- li_parts.append(await render_to_sx("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 await render_to_sx("blog-filter-nav", items=SxExpr(items))
-
-
-async 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 = [await render_to_sx("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 = await render_to_sx("blog-filter-author-icon", src=a_img, name=a_name)
-
- li_parts.append(await render_to_sx("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 await render_to_sx("blog-filter-nav", items=SxExpr(items))
-
-
-async 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 await render_to_sx("blog-filter-summary", text=", ".join(names))
-
-
-
-async 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 await render_to_sx("blog-filter-summary", text=", ".join(names))
-
-
-
-# ---------------------------------------------------------------------------
-# Post detail main panel
-# ---------------------------------------------------------------------------
-
-async 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 = await render_to_sx("blog-detail-edit-link",
- href=edit_href, hx_select=hx_select,
- )
- draft_sx = await render_to_sx("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 = await render_to_sx("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 = await render_to_sx("blog-detail-excerpt",
- excerpt=post["custom_excerpt"],
- )
-
- at_bar = await _at_bar_sx(post, ctx)
- chrome_sx = await render_to_sx("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 await render_to_sx("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,
- )
-
-
-async 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 await render_to_sx("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)
-# ---------------------------------------------------------------------------
-
-async 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 await render_to_sx("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")'
-
-
-async 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 await render_to_sx("blog-cache-panel", clear_url=clear_url, csrf=csrf)
-
-
-# ---------------------------------------------------------------------------
-# Snippets main panel
-# ---------------------------------------------------------------------------
-
-async def _snippets_main_panel_sx(ctx: dict) -> str:
- sl = await _snippets_list_sx(ctx)
- return await render_to_sx("blog-snippets-panel", list=SxExpr(sl))
-
-
-async 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 await render_to_sx("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 += await render_to_sx("blog-snippet-option",
- value=v, selected=(s_vis == v), label=v,
- )
- extra += await render_to_sx("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 += await render_to_sx("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(await render_to_sx("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 await render_to_sx("blog-snippets-list", rows=SxExpr(rows))
-
-
-# ---------------------------------------------------------------------------
-# Menu items main panel
-# ---------------------------------------------------------------------------
-
-async 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 = await _menu_items_list_sx(ctx)
- return await render_to_sx("blog-menu-items-panel", new_url=new_url, list=SxExpr(ml))
-
-
-async 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 await render_to_sx("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 = await render_to_sx("img-or-placeholder", src=fi, alt=label,
- size_cls="w-12 h-12 rounded-full object-cover flex-shrink-0")
-
- row_parts.append(await render_to_sx("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 await render_to_sx("blog-menu-items-list", rows=SxExpr(rows))
-
-
-# ---------------------------------------------------------------------------
-# Tag groups main panel
-# ---------------------------------------------------------------------------
-
-async 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 = await render_to_sx("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 = await render_to_sx("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 = await render_to_sx("blog-tag-group-icon-color", style=style, initial=g_name[:1])
-
- li_parts.append(await render_to_sx("blog-tag-group-li",
- icon=SxExpr(icon), edit_href=edit_href, name=g_name,
- slug=g_slug, sort_order=str(g_sort),
- ))
- groups_sx = await render_to_sx("blog-tag-groups-list", items=SxExpr("(<> " + " ".join(li_parts) + ")"))
- else:
- groups_sx = await render_to_sx("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(await render_to_sx("blog-unassigned-tag", name=t_name))
- unassigned_sx = await render_to_sx("blog-unassigned-tags",
- heading=f"Unassigned Tags ({len(unassigned_tags)})",
- spans=SxExpr("(<> " + " ".join(tag_spans) + ")"),
- )
-
- return await render_to_sx("blog-tag-groups-main",
- form=SxExpr(form_sx),
- groups=SxExpr(groups_sx),
- unassigned=SxExpr(unassigned_sx) if unassigned_sx else None,
- )
-
-
-async 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 = (await render_to_sx("blog-tag-checkbox-image", src=t_fi)) if t_fi else ""
- tag_items.append(await render_to_sx("blog-tag-checkbox",
- tag_id=str(t_id), checked=checked,
- img=SxExpr(img) if img else None, name=t_name,
- ))
-
- edit_form = await render_to_sx("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 = await render_to_sx("blog-tag-group-delete-form",
- delete_url=del_url, csrf=csrf,
- )
-
- return await render_to_sx("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 = await root_header_sx(ctx)
- post_hdr = await _post_header_sx(ctx)
- header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
- content = await _home_main_panel_sx(ctx)
- meta = await _post_meta_sx(ctx)
- menu = mobile_menu_sx(await post_mobile_nav_sx(ctx), await mobile_root_nav_sx(ctx))
- return await full_page_sx(ctx, header_rows=header_rows, content=content,
- meta=meta, menu=menu)
-
-
-async def render_home_oob(ctx: dict) -> str:
- root_hdr = await root_header_sx(ctx)
- post_hdr = await _post_header_sx(ctx)
- rows = "(<> " + root_hdr + " " + post_hdr + ")"
- header_oob = await _oob_header_sx("root-header-child", "post-header-child", rows)
- content = await _home_main_panel_sx(ctx)
- return await oob_page_sx(oobs=header_oob, content=content)
-
-
-# ---- Blog index ----
-
-async def render_blog_page(ctx: dict) -> str:
- root_hdr = await root_header_sx(ctx)
- blog_hdr = await _blog_header_sx(ctx)
- header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
- content = await _blog_main_panel_sx(ctx)
- aside = await _blog_aside_sx(ctx)
- filter_sx = await _blog_filter_sx(ctx)
- return await 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 = await root_header_sx(ctx)
- blog_hdr = await _blog_header_sx(ctx)
- rows = "(<> " + root_hdr + " " + blog_hdr + ")"
- header_oob = await _oob_header_sx("root-header-child", "blog-header-child", rows)
- content = await _blog_main_panel_sx(ctx)
- aside = await _blog_aside_sx(ctx)
- filter_sx = await _blog_filter_sx(ctx)
- return await 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 await _blog_cards_sx(ctx)
-
-
-async def render_blog_page_cards(ctx: dict) -> str:
- """Page cards pagination response."""
- return await _page_cards_sx(ctx)
-
-
-# ---- New post/page editor panel ----
-
-async 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)."""
- 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(await render_to_sx("blog-editor-error", error=str(save_error)))
-
- # Form structure
- form_html = await render_to_sx("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(await render_to_sx("blog-editor-styles", css_href=editor_css))
- parts.append(await render_to_sx("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: (document.getElementById('sx-content-input') || {}).value || 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(await render_to_sx("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 = await root_header_sx(ctx)
- blog_hdr = await _blog_header_sx(ctx)
- header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
- content = ctx.get("editor_html", "")
- return await full_page_sx(ctx, header_rows=header_rows, content=content)
-
-
-# ---- Post detail ----
-
-async def render_post_page(ctx: dict) -> str:
- root_hdr = await root_header_sx(ctx)
- post_hdr = await _post_header_sx(ctx)
- header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
- content = await _post_main_panel_sx(ctx)
- meta = await _post_meta_sx(ctx)
- menu = mobile_menu_sx(await post_mobile_nav_sx(ctx), await mobile_root_nav_sx(ctx))
- return await full_page_sx(ctx, header_rows=header_rows, content=content,
- meta=meta, menu=menu)
-
-
-async def render_post_oob(ctx: dict) -> str:
- root_hdr = await root_header_sx(ctx) # non-OOB (nested inside root-header-child)
- post_hdr = await _post_header_sx(ctx)
- rows = "(<> " + root_hdr + " " + post_hdr + ")"
- post_oob = await _oob_header_sx("root-header-child", "post-header-child", rows)
- content = await _post_main_panel_sx(ctx)
- menu = mobile_menu_sx(await post_mobile_nav_sx(ctx), await mobile_root_nav_sx(ctx))
- oobs = post_oob
- return await 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 (
- '
'
- '
'
- '
'
- '
Field
'
- '
Value
'
- '
' + "".join(rows) + '
'
- )
-
- 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 += (
- '
'
- '
'
- '
#
'
- '
Summary
'
- + "".join(sub_rows) + '
'
- )
- 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}
'
-
- # 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('
{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''
- )
- return _raw_html_sx(html)
-
-
-# ===========================================================================
-
-# ===========================================================================
-
-# ===========================================================================
-
-# ===========================================================================
-
-# ===========================================================================
-
-# ===========================================================================
-
-# ===========================================================================
-# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers
-# ===========================================================================
-
-# ---- Like toggle button (delegates to market impl) ----
-
-async 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 await _market_like(slug, liked, like_url=like_url, item_type="post")
-
-
-# ---- Snippets list ----
-
-async 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 await _snippets_list_sx(ctx)
-
-
-# ---- Menu items list + nav OOB ----
-
-async 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 await _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''
- 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'''
')
+
+ 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 (
+ '
'
+ '
'
+ '
'
+ '
Field
'
+ '
Value
'
+ '
' + "".join(rows) + '
'
+ )
+
+ 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 += (
+ '
'
+ '
'
+ '
#
'
+ '
Summary
'
+ + "".join(sub_rows) + '
'
+ )
+ 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)
async def _h_post_preview_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g
from shared.services.registry import services
- from shared.sx.page import get_template_context
- from sx.sx_components import _preview_main_panel_sx
- preview_data = await services.get("blog_page").preview_data(g.s)
- tctx = await get_template_context()
- tctx.update(preview_data)
- return await _preview_main_panel_sx(tctx)
+ from shared.sx.helpers import render_to_sx
+ from shared.sx.parser import SxExpr, serialize as sx_serialize
+
+ preview = await services.get("blog_page").preview_data(g.s)
+
+ sections: list[str] = []
+ if preview.get("sx_pretty"):
+ sections.append(await render_to_sx("blog-preview-section",
+ title="S-Expression Source", content=SxExpr(preview["sx_pretty"])))
+ if preview.get("json_pretty"):
+ sections.append(await render_to_sx("blog-preview-section",
+ title="Lexical JSON", content=SxExpr(preview["json_pretty"])))
+ if preview.get("sx_rendered"):
+ rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["sx_rendered"])}))'
+ sections.append(await render_to_sx("blog-preview-section",
+ title="SX Rendered", content=SxExpr(rendered_sx)))
+ if preview.get("lex_rendered"):
+ rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["lex_rendered"])}))'
+ sections.append(await render_to_sx("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 await render_to_sx("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))
async def _h_post_entries_content(slug=None, **kw):
await _ensure_post_data(slug)
- from quart import g
+ from quart import g, url_for as qurl
from sqlalchemy import select
+ from markupsafe import escape as esc
from shared.models.calendars import Calendar
+ from shared.utils import host_url
from bp.post.services.entry_associations import get_post_entry_ids
+ from bp.post.admin.routes import _render_associated_entries
+
post_id = g.post_data["post"]["id"]
+ post_slug = g.post_data["post"]["slug"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
@@ -398,21 +719,62 @@ async def _h_post_entries_content(slug=None, **kw):
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
- from shared.sx.page import get_template_context
- from sx.sx_components import _post_entries_content_sx
- tctx = await get_template_context()
- tctx["all_calendars"] = all_calendars
- tctx["associated_entry_ids"] = associated_entry_ids
- return await _post_entries_content_sx(tctx)
+
+ # Associated entries list
+ assoc_html = await _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 = esc(getattr(cal_post, "title", "")) if cal_post else ""
+ cal_name = esc(getattr(cal, "name", ""))
+ cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id))
+
+ img_html = (
+ f''
+ if cal_fi else
+ ''
+ )
+ cal_items.append(
+ f''
+ f''
+ f'{img_html}'
+ 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_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")}
'
+ )
+
+ 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")}
'
+ )
+
+ 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")}
'
+ )
+
+ 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)}