diff --git a/blog/app.py b/blog/app.py index 664f9c0..66d666b 100644 --- a/blog/app.py +++ b/blog/app.py @@ -1,6 +1,5 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, request diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index 1c2fa1b..35367d0 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -21,7 +21,7 @@ from .services.pages_data import pages_data from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.authz import require_admin -from shared.sx.helpers import sx_response +from shared.sx.helpers import sx_response, render_to_sx from shared.utils import host_url def register(url_prefix, title): @@ -62,6 +62,19 @@ def register(url_prefix, title): "unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""), } + async def _render_new_post_page(tctx): + """Compose a full page with blog header for new post/page creation.""" + from shared.sx.helpers import root_header_sx, full_page_sx + from shared.sx.parser import SxExpr + root_hdr = await root_header_sx(tctx) + blog_hdr = await render_to_sx("menu-row-sx", + id="blog-row", level=1, + link_label_content=SxExpr("(div)"), + child_id="blog-header-child") + header_rows = "(<> " + root_hdr + " " + blog_hdr + ")" + content = tctx.get("editor_html", "") + return await full_page_sx(tctx, header_rows=header_rows, content=content) + SORT_MAP = { "newest": "published_at DESC", "oldest": "published_at ASC", @@ -216,19 +229,19 @@ def register(url_prefix, title): lexical_doc = json.loads(lexical_raw) except (json.JSONDecodeError, TypeError): from shared.sx.page import get_template_context - from sx.sx_components import render_new_post_page, render_editor_panel + from sxc.pages import render_editor_panel tctx = await get_template_context() tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.") - html = await render_new_post_page(tctx) + html = await _render_new_post_page(tctx) return await make_response(html, 400) ok, reason = validate_lexical(lexical_doc) if not ok: from shared.sx.page import get_template_context - from sx.sx_components import render_new_post_page, render_editor_panel + from sxc.pages import render_editor_panel tctx = await get_template_context() tctx["editor_html"] = await render_editor_panel(save_error=reason) - html = await render_new_post_page(tctx) + html = await _render_new_post_page(tctx) return await make_response(html, 400) # Create directly in db_blog @@ -272,21 +285,21 @@ def register(url_prefix, title): lexical_doc = json.loads(lexical_raw) except (json.JSONDecodeError, TypeError): from shared.sx.page import get_template_context - from sx.sx_components import render_new_post_page, render_editor_panel + from sxc.pages import render_editor_panel tctx = await get_template_context() tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True) tctx["is_page"] = True - html = await render_new_post_page(tctx) + html = await _render_new_post_page(tctx) return await make_response(html, 400) ok, reason = validate_lexical(lexical_doc) if not ok: from shared.sx.page import get_template_context - from sx.sx_components import render_new_post_page, render_editor_panel + from sxc.pages import render_editor_panel tctx = await get_template_context() tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True) tctx["is_page"] = True - html = await render_new_post_page(tctx) + html = await _render_new_post_page(tctx) return await make_response(html, 400) # Create directly in db_blog diff --git a/blog/bp/menu_items/routes.py b/blog/bp/menu_items/routes.py index 19be754..1ffb89a 100644 --- a/blog/bp/menu_items/routes.py +++ b/blog/bp/menu_items/routes.py @@ -12,7 +12,9 @@ from .services.menu_items import ( search_pages, MenuItemError, ) +from markupsafe import escape from shared.sx.helpers import sx_response, render_to_sx +from shared.sx.parser import SxExpr from shared.browser.app.csrf import generate_csrf_token @@ -34,20 +36,193 @@ async def _render_menu_items_list(menu_items): menu_items=items, new_url=new_url, csrf=csrf) +def _render_menu_item_form(menu_item=None) -> str: + """Render menu item add/edit form.""" + csrf = generate_csrf_token() + search_url = url_for("menu_items.search_pages_route") + is_edit = menu_item is not None + + if is_edit: + action_url = url_for("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 = url_for("menu_items.create_menu_item_route") + action_attr = f'sx-post="{action_url}"' + post_id = "" + label = "" + slug = "" + fi = "" + + 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 + + +async def _render_page_search_results(pages, query, page, has_more) -> str: + """Render page search results.""" + if not pages and query: + return await render_to_sx("page-search-empty", query=query) + if not pages: + return "" + + items = [] + for post in pages: + items.append(await render_to_sx("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 = url_for("menu_items.search_pages_route") + sentinel = await render_to_sx("page-search-sentinel", + url=search_url, query=query, + next_page=page + 1) + + items_sx = "(<> " + " ".join(items) + ")" + return await render_to_sx("page-search-results", + items=SxExpr(items_sx), + sentinel=SxExpr(sentinel) if sentinel else None) + + +async def _render_menu_items_nav_oob(menu_items) -> str: + """Render OOB nav update for menu items.""" + from quart import request as qrequest + + if not menu_items: + return await render_to_sx("blog-nav-empty", wrapper_id="menu-items-nav-wrapper") + + first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else "" + + 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" + ) + + 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") + + href = f"/{item_slug}/" + selected = "true" if item_slug == first_seg else "false" + + img_sx = await render_to_sx("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(await render_to_sx("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(await render_to_sx("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 await render_to_sx("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, + ) + + def register(): bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items') async def get_menu_items_nav_oob_async(menu_items): """Helper to generate OOB update for root nav menu items""" - from sx.sx_components import render_menu_items_nav_oob - return await render_menu_items_nav_oob(menu_items) + return await _render_menu_items_nav_oob(menu_items) @bp.get("/new/") @require_admin async def new_menu_item(): """Show form to create new menu item""" - from sx.sx_components import render_menu_item_form - return sx_response(render_menu_item_form()) + return sx_response(_render_menu_item_form()) @bp.post("/") @require_admin @@ -85,8 +260,7 @@ def register(): if not menu_item: return await make_response("Menu item not found", 404) - from sx.sx_components import render_menu_item_form - return sx_response(render_menu_item_form(menu_item=menu_item)) + return sx_response(_render_menu_item_form(menu_item=menu_item)) @bp.put("//") @require_admin @@ -144,8 +318,7 @@ def register(): pages, total = await search_pages(g.s, query, page, per_page) has_more = (page * per_page) < total - from sx.sx_components import render_page_search_results - return sx_response(await render_page_search_results(pages, query, page, has_more)) + return sx_response(await _render_page_search_results(pages, query, page, has_more)) @bp.post("/reorder/") @require_admin diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index a779e8a..21ad526 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -10,9 +10,18 @@ from quart import ( url_for, ) from shared.browser.app.authz import require_admin, require_post_author +from markupsafe import escape from shared.sx.helpers import sx_response, render_to_sx +from shared.sx.parser import SxExpr, serialize as sx_serialize from shared.utils import host_url + +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_to_edit_dict(post) -> dict: """Convert an ORM Post to a dict matching the shape templates expect. @@ -81,6 +90,232 @@ def _serialize_markets(markets, slug): return result +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.""" + from quart import url_for as qurl + from shared.browser.app.csrf import generate_csrf_token + 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))) + + nav = ( + f'
' + f'
' + ) + + wd_cells = "".join(f'
{esc(wd)}
' for wd in weekday_names) + wd_row = f'' + + 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) + + +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 ( - '
' - '' - '' - '' - '' - '' + "".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) - - -# =========================================================================== - -async 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(await render_to_sx("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(await render_to_sx("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(await render_to_sx("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(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 _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 = 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 = 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) + ")" - - -async def _post_edit_content_sx(ctx: dict) -> str: - """Build WYSIWYG editor panel as SX expression (edit page).""" - from quart import url_for as qurl, current_app, g, request as qrequest - from shared.browser.app.csrf import generate_csrf_token - - 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 = ghost_post.get("title") or "" - excerpt_val = ghost_post.get("custom_excerpt") or "" - updated_at = 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 "" - has_sx = bool(sx_content) - - already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status")) - email_obj = ghost_post.get("email") - if email_obj and not isinstance(email_obj, dict): - already_emailed = bool(getattr(email_obj, "status", None)) - - title_placeholder = "Page title..." if is_page else "Post title..." - - # Newsletter options as SX fragment - nl_parts = ['(option :value "" "Select newsletter\u2026")'] - for nl in newsletters: - nl_slug = sx_serialize(getattr(nl, "slug", "")) - nl_name = sx_serialize(getattr(nl, "name", "")) - nl_parts.append(f"(option :value {nl_slug} {nl_name})") - nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")") - - # Footer extra badges as SX fragment - badge_parts: list[str] = [] - if save_success: - badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")') - publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None - if publish_requested: - badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")') - if post.get("publish_requested"): - badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")') - 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 {nl_name}" if nl_name else "" - badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")') - footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None - - parts: list[str] = [] - - # Error banner - if save_error: - parts.append(await render_to_sx("blog-editor-error", error=save_error)) - - # Form (sx_content_val populates #sx-content-input; JS reads from there) - parts.append(await render_to_sx("blog-editor-edit-form", - csrf=csrf, - updated_at=str(updated_at), - title_val=title_val, - excerpt_val=excerpt_val, - feature_image=feature_image, - feature_image_caption=feature_image_caption, - sx_content_val=sx_content, - lexical_json=lexical_json, - has_sx=has_sx, - title_placeholder=title_placeholder, - status=status, - already_emailed=already_emailed, - newsletter_options=nl_opts_sx, - footer_extra=footer_extra_sx, - )) - - # Publish-mode JS - parts.append(await render_to_sx("blog-editor-publish-js", already_emailed=already_emailed)) - - # Editor CSS + 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 - init_js = ( - '(function() {' - " function applyEditorFontSize() {" - " document.documentElement.style.fontSize = '62.5%';" - " document.body.style.fontSize = '1.6rem';" - ' }' - " function restoreDefaultFontSize() {" - " document.documentElement.style.fontSize = '';" - " document.body.style.fontSize = '';" - ' }' - ' applyEditorFontSize();' - " document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {" - " if (e.detail.target && e.detail.target.id === 'main-panel') {" - ' restoreDefaultFontSize();' - " document.body.removeEventListener('htmx:beforeSwap', cleanup);" - ' }' - ' });' - ' function init() {' - " var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;" - f" var uploadUrl = '{upload_image_url}';" - ' var uploadUrls = {' - ' image: uploadUrl,' - f" media: '{upload_media_url}'," - f" file: '{upload_file_url}'," - ' };' - " var fileInput = document.getElementById('feature-image-file');" - " var addBtn = document.getElementById('feature-image-add-btn');" - " var deleteBtn = document.getElementById('feature-image-delete-btn');" - " var preview = document.getElementById('feature-image-preview');" - " var emptyState = document.getElementById('feature-image-empty');" - " var filledState = document.getElementById('feature-image-filled');" - " var hiddenUrl = document.getElementById('feature-image-input');" - " var hiddenCaption = document.getElementById('feature-image-caption-input');" - " var captionInput = document.getElementById('feature-image-caption');" - " var uploading = document.getElementById('feature-image-uploading');" - ' function showFilled(url) {' - ' preview.src = url; hiddenUrl.value = url;' - " emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');" - ' }' - ' function showEmpty() {' - " preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';" - " emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');" - ' }' - ' function uploadFile(file) {' - " emptyState.classList.add('hidden'); uploading.classList.remove('hidden');" - " var fd = new FormData(); fd.append('file', file);" - " fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })" - " .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })" - ' .then(function(data) {' - ' var url = data.images && data.images[0] && data.images[0].url;' - " if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }" - ' })' - ' .catch(function(e) { showEmpty(); alert(e.message); });' - ' }' - " addBtn.addEventListener('click', function() { fileInput.click(); });" - " preview.addEventListener('click', function() { fileInput.click(); });" - " deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });" - " fileInput.addEventListener('change', function() {" - ' if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }' - ' });' - " captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });" - " var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');" - " function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }" - " excerpt.addEventListener('input', autoResize); autoResize();" - ' var dataEl = document.getElementById(\'lexical-initial-data\');' - ' var initialJson = dataEl ? dataEl.textContent.trim() : null;' - ' if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }' - " window.mountEditor('lexical-editor', {" - ' initialJson: initialJson,' - ' csrfToken: csrfToken,' - ' uploadUrls: uploadUrls,' - f" oembedUrl: '{oembed_url}'," - f" unsplashApiKey: '{unsplash_key}'," - f" snippetsUrl: '{snippets_url}'," - ' });' - " if (typeof SxEditor !== 'undefined') {" - " SxEditor.mount('sx-editor', {" - " initialSx: (document.getElementById('sx-content-input') || {}).value || null," - ' csrfToken: csrfToken,' - ' uploadUrls: uploadUrls,' - f" oembedUrl: '{oembed_url}'," - ' onChange: function(sx) {' - " document.getElementById('sx-content-input').value = sx;" - ' }' - ' });' - ' }' - " document.addEventListener('keydown', function(e) {" - " if ((e.ctrlKey || e.metaKey) && e.key === 's') {" - " e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();" - ' }' - ' });' - ' }' - " if (typeof window.mountEditor === 'function') { init(); }" - ' else { var _t = setInterval(function() {' - " if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }" - ' }, 50); }' - '})();' - ) - 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) + ")" - - -# =========================================================================== - -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) ---- - -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'{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 - - -async 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 await render_to_sx("page-search-empty", query=query) - - if not pages: - return "" - - items = [] - for post in pages: - items.append(await render_to_sx("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 = await render_to_sx("page-search-sentinel", - url=search_url, query=query, - next_page=page + 1) - - items_sx = "(<> " + " ".join(items) + ")" - return await render_to_sx("page-search-results", - items=SxExpr(items_sx), - sentinel=SxExpr(sentinel) if sentinel else None) - - -async 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 await render_to_sx("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 = await render_to_sx("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(await render_to_sx("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(await render_to_sx("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 await render_to_sx("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 ---- - -async 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 = await render_to_sx("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 = await render_to_sx("blog-sumup-form", - sumup_url=sumup_url, merchant_code=sumup_merchant_code, - placeholder=placeholder, - sumup_configured=sumup_configured, - checkout_prefix=sumup_checkout_prefix, - ) - - return await render_to_sx("blog-features-panel", - form=SxExpr(form_sx), - sumup=SxExpr(sumup_sx) if sumup_sx else None, - ) - - -# ---- Markets panel ---- - -async 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(await render_to_sx("blog-market-item", - name=m_name, slug=m_slug, delete_url=del_url, - confirm_text=f"Delete market '{m_name}'?", - )) - list_sx = await render_to_sx("blog-markets-list", items=SxExpr("(<> " + " ".join(li_parts) + ")")) - else: - list_sx = await render_to_sx("blog-markets-empty") - - return await render_to_sx("blog-markets-panel", - list=SxExpr(list_sx), create_url=create_url, - ) - - -# ---- Associated entries ---- - -async 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 = 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)) - - -# ---- Nav entries OOB ---- - -async 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 await render_to_sx("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(await render_to_sx("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(await render_to_sx("blog-nav-calendar-item", - href=href, 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, - ) diff --git a/blog/sxc/pages/__init__.py b/blog/sxc/pages/__init__.py index adb498e..62d180d 100644 --- a/blog/sxc/pages/__init__.py +++ b/blog/sxc/pages/__init__.py @@ -14,6 +14,11 @@ def setup_blog_pages() -> None: def _load_blog_page_files() -> None: import os from shared.sx.pages import load_page_dir + from shared.sx.jinja_bridge import load_service_components + # Load blog .sx component definitions + handler definitions + # __file__ = blog/sxc/pages/__init__.py → blog root is 3 levels up + blog_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + load_service_components(blog_dir, service_name="blog") load_page_dir(os.path.dirname(__file__), "blog") @@ -325,6 +330,191 @@ async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str: return "(<> " + settings_hdr_oob + " " + sub_oob + ")" +# --------------------------------------------------------------------------- +# Rendering helpers (moved from sx_components) +# --------------------------------------------------------------------------- + +def _raw_html_sx(html: str) -> str: + """Wrap raw HTML in (raw! "...") so it's valid inside sx source.""" + from shared.sx.parser import serialize as sx_serialize + if not html: + return "" + return "(raw! " + sx_serialize(html) + ")" + + +async def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str: + """Build the WYSIWYG editor panel HTML for new post/page creation.""" + import os + from quart import url_for as qurl, current_app + from shared.browser.app.csrf import generate_csrf_token + from shared.sx.helpers import render_to_sx + + 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] = [] + + if save_error: + parts.append(await render_to_sx("blog-editor-error", error=str(save_error))) + + parts.append(await render_to_sx("blog-editor-form", + csrf=csrf, title_placeholder=title_placeholder, + create_label=create_label, + )) + + parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css)) + parts.append(await render_to_sx("sx-editor-styles")) + + init_js = ( + "console.log('[EDITOR-DEBUG] init script running');\n" + "(function() {\n" + " console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\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 "" + + # --------------------------------------------------------------------------- # Page helpers (async functions available in .sx defpage expressions) # --------------------------------------------------------------------------- @@ -346,12 +536,10 @@ def _register_blog_helpers() -> None: # --- Editor helpers --- async def _h_editor_content(**kw): - from sx.sx_components import render_editor_panel return await render_editor_panel() async def _h_editor_page_content(**kw): - from sx.sx_components import render_editor_panel return await render_editor_panel(is_page=True) @@ -364,31 +552,164 @@ async def _h_post_admin_content(slug=None, **kw): async def _h_post_data_content(slug=None, **kw): await _ensure_post_data(slug) - from shared.sx.page import get_template_context - from sx.sx_components import _post_data_content_sx - tctx = await get_template_context() - return _post_data_content_sx(tctx) + from quart import g + from markupsafe import escape as esc + + 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) 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'{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.
' + + return ( + _raw_html_sx('
') + + assoc_html + + _raw_html_sx(browser_html + '
') + ) async def _h_post_settings_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g, request + from markupsafe import escape as esc from models.ghost_content import Post from sqlalchemy import select as sa_select from sqlalchemy.orm import selectinload + from shared.browser.app.csrf import generate_csrf_token from bp.post.admin.routes import _post_to_edit_dict + post_id = g.post_data["post"]["id"] post = (await g.s.execute( sa_select(Post) @@ -421,41 +783,339 @@ async def _h_post_settings_content(slug=None, **kw): )).scalar_one_or_none() ghost_post = _post_to_edit_dict(post) if post else {} save_success = request.args.get("saved") == "1" - from shared.sx.page import get_template_context - from sx.sx_components import _post_settings_content_sx - tctx = await get_template_context() - tctx["ghost_post"] = ghost_post - tctx["save_success"] = save_success - return _post_settings_content_sx(tctx) + csrf = generate_csrf_token() + + p = g.post_data.get("post", {}) if hasattr(g, "post_data") else {} + is_page = p.get("is_page", False) + gp = ghost_post + + 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}
') + + # 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.

' + ) + + 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_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)}
' + + 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) async def _h_post_edit_content(slug=None, **kw): await _ensure_post_data(slug) - from quart import g, request + import os + from quart import g, request as qrequest, url_for as qurl, current_app from models.ghost_content import Post from sqlalchemy import select as sa_select from sqlalchemy.orm import selectinload from shared.infrastructure.data_client import fetch_data + from shared.browser.app.csrf import generate_csrf_token + from shared.sx.helpers import render_to_sx + from shared.sx.parser import SxExpr, serialize as sx_serialize from bp.post.admin.routes import _post_to_edit_dict + post_id = g.post_data["post"]["id"] - post = (await g.s.execute( + db_post = (await g.s.execute( sa_select(Post) .where(Post.id == post_id) .options(selectinload(Post.tags)) )).scalar_one_or_none() - ghost_post = _post_to_edit_dict(post) if post else {} - save_success = request.args.get("saved") == "1" - save_error = request.args.get("error", "") + ghost_post = _post_to_edit_dict(db_post) if db_post else {} + save_success = qrequest.args.get("saved") == "1" + save_error = qrequest.args.get("error", "") raw_newsletters = await fetch_data("account", "newsletters", required=False) or [] from types import SimpleNamespace newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] - from shared.sx.page import get_template_context - from sx.sx_components import _post_edit_content_sx - tctx = await get_template_context() - tctx["ghost_post"] = ghost_post - tctx["save_success"] = save_success - tctx["save_error"] = save_error - tctx["newsletters"] = newsletters - return await _post_edit_content_sx(tctx) + + 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 = ghost_post.get("title") or "" + excerpt_val = ghost_post.get("custom_excerpt") or "" + updated_at = 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 "" + has_sx = bool(sx_content) + + already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status")) + email_obj = ghost_post.get("email") + if email_obj and not isinstance(email_obj, dict): + already_emailed = bool(getattr(email_obj, "status", None)) + + title_placeholder = "Page title..." if is_page else "Post title..." + + # Newsletter options as SX fragment + nl_parts = ['(option :value "" "Select newsletter\u2026")'] + for nl in newsletters: + nl_slug = sx_serialize(getattr(nl, "slug", "")) + nl_name = sx_serialize(getattr(nl, "name", "")) + nl_parts.append(f"(option :value {nl_slug} {nl_name})") + nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")") + + # Footer extra badges as SX fragment + badge_parts: list[str] = [] + if save_success: + badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")') + publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None + if publish_requested: + badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")') + if post.get("publish_requested"): + badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")') + 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 {nl_name}" if nl_name else "" + badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")') + footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None + + parts: list[str] = [] + + if save_error: + parts.append(await render_to_sx("blog-editor-error", error=save_error)) + + parts.append(await render_to_sx("blog-editor-edit-form", + csrf=csrf, + updated_at=str(updated_at), + title_val=title_val, + excerpt_val=excerpt_val, + feature_image=feature_image, + feature_image_caption=feature_image_caption, + sx_content_val=sx_content, + lexical_json=lexical_json, + has_sx=has_sx, + title_placeholder=title_placeholder, + status=status, + already_emailed=already_emailed, + newsletter_options=nl_opts_sx, + footer_extra=footer_extra_sx, + )) + + parts.append(await render_to_sx("blog-editor-publish-js", already_emailed=already_emailed)) + parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css)) + parts.append(await render_to_sx("sx-editor-styles")) + + init_js = ( + '(function() {' + " function applyEditorFontSize() {" + " document.documentElement.style.fontSize = '62.5%';" + " document.body.style.fontSize = '1.6rem';" + ' }' + " function restoreDefaultFontSize() {" + " document.documentElement.style.fontSize = '';" + " document.body.style.fontSize = '';" + ' }' + ' applyEditorFontSize();' + " document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {" + " if (e.detail.target && e.detail.target.id === 'main-panel') {" + ' restoreDefaultFontSize();' + " document.body.removeEventListener('htmx:beforeSwap', cleanup);" + ' }' + ' });' + ' function init() {' + " var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;" + f" var uploadUrl = '{upload_image_url}';" + ' var uploadUrls = {' + ' image: uploadUrl,' + f" media: '{upload_media_url}'," + f" file: '{upload_file_url}'," + ' };' + " var fileInput = document.getElementById('feature-image-file');" + " var addBtn = document.getElementById('feature-image-add-btn');" + " var deleteBtn = document.getElementById('feature-image-delete-btn');" + " var preview = document.getElementById('feature-image-preview');" + " var emptyState = document.getElementById('feature-image-empty');" + " var filledState = document.getElementById('feature-image-filled');" + " var hiddenUrl = document.getElementById('feature-image-input');" + " var hiddenCaption = document.getElementById('feature-image-caption-input');" + " var captionInput = document.getElementById('feature-image-caption');" + " var uploading = document.getElementById('feature-image-uploading');" + ' function showFilled(url) {' + ' preview.src = url; hiddenUrl.value = url;' + " emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');" + ' }' + ' function showEmpty() {' + " preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';" + " emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');" + ' }' + ' function uploadFile(file) {' + " emptyState.classList.add('hidden'); uploading.classList.remove('hidden');" + " var fd = new FormData(); fd.append('file', file);" + " fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })" + " .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })" + ' .then(function(data) {' + ' var url = data.images && data.images[0] && data.images[0].url;' + " if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }" + ' })' + ' .catch(function(e) { showEmpty(); alert(e.message); });' + ' }' + " addBtn.addEventListener('click', function() { fileInput.click(); });" + " preview.addEventListener('click', function() { fileInput.click(); });" + " deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });" + " fileInput.addEventListener('change', function() {" + ' if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }' + ' });' + " captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });" + " var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');" + " function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }" + " excerpt.addEventListener('input', autoResize); autoResize();" + ' var dataEl = document.getElementById(\'lexical-initial-data\');' + ' var initialJson = dataEl ? dataEl.textContent.trim() : null;' + ' if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }' + " window.mountEditor('lexical-editor', {" + ' initialJson: initialJson,' + ' csrfToken: csrfToken,' + ' uploadUrls: uploadUrls,' + f" oembedUrl: '{oembed_url}'," + f" unsplashApiKey: '{unsplash_key}'," + f" snippetsUrl: '{snippets_url}'," + ' });' + " if (typeof SxEditor !== 'undefined') {" + " SxEditor.mount('sx-editor', {" + " initialSx: (document.getElementById('sx-content-input') || {}).value || null," + ' csrfToken: csrfToken,' + ' uploadUrls: uploadUrls,' + f" oembedUrl: '{oembed_url}'," + ' onChange: function(sx) {' + " document.getElementById('sx-content-input').value = sx;" + ' }' + ' });' + ' }' + " document.addEventListener('keydown', function(e) {" + " if ((e.ctrlKey || e.metaKey) && e.key === 's') {" + " e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();" + ' }' + ' });' + ' }' + " if (typeof window.mountEditor === 'function') { init(); }" + ' else { var _t = setInterval(function() {' + " if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }" + ' }, 50); }' + '})();' + ) + 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) + ")"