diff --git a/account/app.py b/account/app.py index 5c7296e..c5c375b 100644 --- a/account/app.py +++ b/account/app.py @@ -72,9 +72,19 @@ def create_app() -> "Quart": app.jinja_loader, ]) + # Setup defpage routes + import sx.sx_components # noqa: F811 — ensure components loaded + from sxc.pages import setup_account_pages + setup_account_pages() + # --- blueprints --- app.register_blueprint(register_auth_bp()) - app.register_blueprint(register_account_bp()) + + account_bp = register_account_bp() + from shared.sx.pages import mount_pages + mount_pages(account_bp, "account") + app.register_blueprint(account_bp) + app.register_blueprint(register_fragments()) from bp.actions.routes import register as register_actions diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index 3aa4209..9c18757 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -1,14 +1,13 @@ """Account pages blueprint. Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings). -Mounted at root /. +Mounted at root /. GET page handlers replaced by defpage. """ from __future__ import annotations from quart import ( Blueprint, request, - make_response, redirect, g, ) @@ -20,85 +19,62 @@ from shared.infrastructure.urls import login_url from shared.infrastructure.fragments import fetch_fragment, fetch_fragments from shared.sx.helpers import sx_response -oob = { - "oob_extends": "oob_elements.html", - "extends": "_types/root/_index.html", - "parent_id": "root-header-child", - "child_id": "auth-header-child", - "header": "_types/auth/header/_header.html", - "parent_header": "_types/root/header/_header.html", - "nav": "_types/auth/_nav.html", - "main": "_types/auth/_main_panel.html", -} - def register(url_prefix="/"): account_bp = Blueprint("account", __name__, url_prefix=url_prefix) - @account_bp.context_processor - async def context(): + @account_bp.before_request + async def _prepare_page_data(): + """Fetch account_nav fragments and load data for defpage routes.""" + # Fetch account nav items for layout (was in context_processor) events_nav, cart_nav, artdag_nav = await fetch_fragments([ ("events", "account-nav-item", {}), ("cart", "account-nav-item", {}), ("artdag", "nav-item", {}), ], required=False) - return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav} + g.account_nav = events_nav + cart_nav + artdag_nav - @account_bp.get("/") - async def account(): - from shared.browser.app.utils.htmx import is_htmx_request - from shared.sx.page import get_template_context - from sx.sx_components import render_account_page, render_account_oob + if request.method != "GET": + return - if not g.get("user"): - return redirect(login_url("/")) + endpoint = request.endpoint or "" - ctx = await get_template_context() - if not is_htmx_request(): - html = await render_account_page(ctx) - return await make_response(html) - else: - sx_src = await render_account_oob(ctx) - return sx_response(sx_src) - - @account_bp.get("/newsletters/") - async def newsletters(): - from shared.browser.app.utils.htmx import is_htmx_request - - if not g.get("user"): - return redirect(login_url("/newsletters/")) - - result = await g.s.execute( - select(GhostNewsletter).order_by(GhostNewsletter.name) - ) - all_newsletters = result.scalars().all() - - sub_result = await g.s.execute( - select(UserNewsletter).where( - UserNewsletter.user_id == g.user.id, + # Newsletters page — load newsletter data + if endpoint.endswith("defpage_newsletters"): + result = await g.s.execute( + select(GhostNewsletter).order_by(GhostNewsletter.name) ) - ) - user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} + all_newsletters = result.scalars().all() - newsletter_list = [] - for nl in all_newsletters: - un = user_subs.get(nl.id) - newsletter_list.append({ - "newsletter": nl, - "un": un, - "subscribed": un.subscribed if un else False, - }) + sub_result = await g.s.execute( + select(UserNewsletter).where( + UserNewsletter.user_id == g.user.id, + ) + ) + user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} - from shared.sx.page import get_template_context - from sx.sx_components import render_newsletters_page, render_newsletters_oob + newsletter_list = [] + for nl in all_newsletters: + un = user_subs.get(nl.id) + newsletter_list.append({ + "newsletter": nl, + "un": un, + "subscribed": un.subscribed if un else False, + }) + g.newsletters_data = newsletter_list - ctx = await get_template_context() - if not is_htmx_request(): - html = await render_newsletters_page(ctx, newsletter_list) - return await make_response(html) - else: - sx_src = await render_newsletters_oob(ctx, newsletter_list) - return sx_response(sx_src) + # Fragment page — load fragment from events service + elif endpoint.endswith("defpage_fragment_page"): + slug = request.view_args.get("slug") + if slug and g.get("user"): + fragment_html = await fetch_fragment( + "events", "account-page", + params={"slug": slug, "user_id": str(g.user.id)}, + ) + if not fragment_html: + from quart import abort + abort(404) + g.fragment_page_data = fragment_html @account_bp.post("/newsletter//toggle/") async def toggle_newsletter(newsletter_id: int): @@ -128,31 +104,4 @@ def register(url_prefix="/"): from sx.sx_components import render_newsletter_toggle return sx_response(render_newsletter_toggle(un)) - # Catch-all for fragment-provided pages — must be last - @account_bp.get("//") - async def fragment_page(slug): - from shared.browser.app.utils.htmx import is_htmx_request - from quart import abort - - if not g.get("user"): - return redirect(login_url(f"/{slug}/")) - - fragment_html = await fetch_fragment( - "events", "account-page", - params={"slug": slug, "user_id": str(g.user.id)}, - ) - if not fragment_html: - abort(404) - - from shared.sx.page import get_template_context - from sx.sx_components import render_fragment_page, render_fragment_oob - - ctx = await get_template_context() - if not is_htmx_request(): - html = await render_fragment_page(ctx, fragment_html) - return await make_response(html) - else: - sx_src = await render_fragment_oob(ctx, fragment_html) - return sx_response(sx_src) - return account_bp diff --git a/account/sx/sx_components.py b/account/sx/sx_components.py index 229466a..75eb232 100644 --- a/account/sx/sx_components.py +++ b/account/sx/sx_components.py @@ -12,7 +12,7 @@ from typing import Any from shared.sx.jinja_bridge import load_service_components from shared.sx.helpers import ( call_url, sx_call, SxExpr, - root_header_sx, full_page_sx, header_child_sx, oob_page_sx, + root_header_sx, full_page_sx, ) # Load account-specific .sx components + handlers at import time @@ -238,88 +238,8 @@ def _device_approved_content() -> str: # Public API: Account dashboard # --------------------------------------------------------------------------- -async def render_account_page(ctx: dict) -> str: - """Full page: account dashboard.""" - main = _account_main_panel_sx(ctx) - - hdr = root_header_sx(ctx) - hdr_child = header_child_sx(_auth_header_sx(ctx)) - header_rows = "(<> " + hdr + " " + hdr_child + ")" - - return full_page_sx(ctx, header_rows=header_rows, - content=main, - menu=_auth_nav_mobile_sx(ctx)) -async def render_account_oob(ctx: dict) -> str: - """OOB response for account dashboard.""" - main = _account_main_panel_sx(ctx) - - oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" - - return oob_page_sx(oobs=oobs, - content=main, - menu=_auth_nav_mobile_sx(ctx)) - - -# --------------------------------------------------------------------------- -# Public API: Newsletters -# --------------------------------------------------------------------------- - -async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str: - """Full page: newsletters.""" - main = _newsletters_panel_sx(ctx, newsletter_list) - - hdr = root_header_sx(ctx) - hdr_child = header_child_sx(_auth_header_sx(ctx)) - header_rows = "(<> " + hdr + " " + hdr_child + ")" - - return full_page_sx(ctx, header_rows=header_rows, - content=main, - menu=_auth_nav_mobile_sx(ctx)) - - -async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: - """OOB response for newsletters.""" - main = _newsletters_panel_sx(ctx, newsletter_list) - - oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" - - return oob_page_sx(oobs=oobs, - content=main, - menu=_auth_nav_mobile_sx(ctx)) - - -# --------------------------------------------------------------------------- -# Public API: Fragment pages -# --------------------------------------------------------------------------- - -async def render_fragment_page(ctx: dict, page_fragment: str) -> str: - """Full page: fragment-provided content. - - *page_fragment* may be sx source (from text/sx fragments wrapped in - SxExpr) or HTML (from text/html fragments). Sx source is embedded - directly; HTML is wrapped in ``~rich-text``. - """ - from shared.sx.parser import SxExpr - hdr = root_header_sx(ctx) - hdr_child = header_child_sx(_auth_header_sx(ctx)) - header_rows = "(<> " + hdr + " " + hdr_child + ")" - - content = _fragment_content(page_fragment) - return full_page_sx(ctx, header_rows=header_rows, - content=content, - menu=_auth_nav_mobile_sx(ctx)) - - -async def render_fragment_oob(ctx: dict, page_fragment: str) -> str: - """OOB response for fragment pages.""" - oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" - - content = _fragment_content(page_fragment) - return oob_page_sx(oobs=oobs, - content=content, - menu=_auth_nav_mobile_sx(ctx)) def _fragment_content(frag: object) -> str: diff --git a/account/sxc/__init__.py b/account/sxc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/sxc/pages/__init__.py b/account/sxc/pages/__init__.py new file mode 100644 index 0000000..6e3e8c6 --- /dev/null +++ b/account/sxc/pages/__init__.py @@ -0,0 +1,105 @@ +"""Account defpage setup — registers layouts, page helpers, and loads .sx pages.""" +from __future__ import annotations + +from typing import Any + + +def setup_account_pages() -> None: + """Register account-specific layouts, page helpers, and load page definitions.""" + _register_account_layouts() + _register_account_helpers() + _load_account_page_files() + + +def _load_account_page_files() -> None: + import os + from shared.sx.pages import load_page_dir + load_page_dir(os.path.dirname(__file__), "account") + + +# --------------------------------------------------------------------------- +# Layouts +# --------------------------------------------------------------------------- + +def _register_account_layouts() -> None: + from shared.sx.layouts import register_custom_layout + register_custom_layout("account", _account_full, _account_oob, _account_mobile) + + +def _account_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx, header_child_sx + from sx.sx_components import _auth_header_sx + + root_hdr = root_header_sx(ctx) + hdr_child = header_child_sx(_auth_header_sx(ctx)) + return "(<> " + root_hdr + " " + hdr_child + ")" + + +def _account_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx + from sx.sx_components import _auth_header_sx + + return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" + + +def _account_mobile(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr + from sx.sx_components import _auth_nav_mobile_sx + ctx = _inject_account_nav(ctx) + auth_section = sx_call("mobile-menu-section", + label="account", href="/", level=1, colour="sky", + items=SxExpr(_auth_nav_mobile_sx(ctx))) + return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx)) + + +def _inject_account_nav(ctx: dict) -> dict: + """Ensure account_nav is in ctx from g.account_nav.""" + if "account_nav" not in ctx: + from quart import g + ctx = dict(ctx) + ctx["account_nav"] = getattr(g, "account_nav", "") + return ctx + + +# --------------------------------------------------------------------------- +# Page helpers +# --------------------------------------------------------------------------- + +def _register_account_helpers() -> None: + from shared.sx.pages import register_page_helpers + + register_page_helpers("account", { + "account-content": _h_account_content, + "newsletters-content": _h_newsletters_content, + "fragment-content": _h_fragment_content, + }) + + +def _h_account_content(): + from sx.sx_components import _account_main_panel_sx + return _account_main_panel_sx({}) + + +def _h_newsletters_content(): + from quart import g + d = getattr(g, "newsletters_data", None) + if not d: + from shared.sx.helpers import sx_call + return sx_call("account-newsletter-empty") + from shared.sx.page import get_template_context_sync + from sx.sx_components import _newsletters_panel_sx + # Build a minimal ctx with account_url + ctx = {"account_url": getattr(g, "_account_url", None)} + if ctx["account_url"] is None: + from shared.infrastructure.urls import account_url + ctx["account_url"] = account_url + return _newsletters_panel_sx(ctx, d) + + +def _h_fragment_content(): + from quart import g + frag = getattr(g, "fragment_page_data", None) + if not frag: + return "" + from sx.sx_components import _fragment_content + return _fragment_content(frag) diff --git a/account/sxc/pages/account.sx b/account/sxc/pages/account.sx new file mode 100644 index 0000000..c175285 --- /dev/null +++ b/account/sxc/pages/account.sx @@ -0,0 +1,31 @@ +;; Account app — declarative page definitions + +;; --------------------------------------------------------------------------- +;; Account dashboard +;; --------------------------------------------------------------------------- + +(defpage account-dashboard + :path "/" + :auth :login + :layout :account + :content (account-content)) + +;; --------------------------------------------------------------------------- +;; Newsletters +;; --------------------------------------------------------------------------- + +(defpage newsletters + :path "/newsletters/" + :auth :login + :layout :account + :content (newsletters-content)) + +;; --------------------------------------------------------------------------- +;; Fragment pages (tickets, bookings, etc. from events service) +;; --------------------------------------------------------------------------- + +(defpage fragment-page + :path "//" + :auth :login + :layout :account + :content (fragment-content)) diff --git a/blog/app.py b/blog/app.py index 91d1258..bba7428 100644 --- a/blog/app.py +++ b/blog/app.py @@ -20,6 +20,7 @@ from bp import ( register_data, register_actions, ) +from sxc.pages import setup_blog_pages async def blog_context() -> dict: @@ -80,6 +81,8 @@ async def blog_context() -> dict: def create_app() -> "Quart": from services import register_domain_services + setup_blog_pages() + app = create_base_app( "blog", context_fn=blog_context, diff --git a/blog/bp/admin/routes.py b/blog/bp/admin/routes.py index b2b560c..c69642a 100644 --- a/blog/bp/admin/routes.py +++ b/blog/bp/admin/routes.py @@ -27,33 +27,22 @@ def register(url_prefix): "base_title": f"{config()['title']} settings", } - @bp.get("/") - @require_admin - async def home(): - from shared.sx.page import get_template_context - from sx.sx_components import render_settings_page, render_settings_oob + @bp.before_request + async def _prepare_page_data(): + ep = request.endpoint or "" + if "defpage_settings_home" in ep: + from shared.sx.page import get_template_context + from sx.sx_components import _settings_main_panel_sx + tctx = await get_template_context() + g.settings_content = _settings_main_panel_sx(tctx) + elif "defpage_cache_page" in ep: + from shared.sx.page import get_template_context + from sx.sx_components import _cache_main_panel_sx + tctx = await get_template_context() + g.cache_content = _cache_main_panel_sx(tctx) - tctx = await get_template_context() - if not is_htmx_request(): - html = await render_settings_page(tctx) - return await make_response(html) - else: - sx_src = await render_settings_oob(tctx) - return sx_response(sx_src) - - @bp.get("/cache/") - @require_admin - async def cache(): - from shared.sx.page import get_template_context - from sx.sx_components import render_cache_page, render_cache_oob - - tctx = await get_template_context() - if not is_htmx_request(): - html = await render_cache_page(tctx) - return await make_response(html) - else: - sx_src = await render_cache_oob(tctx) - return sx_response(sx_src) + from shared.sx.pages import mount_pages + mount_pages(bp, "blog", names=["settings-home", "cache-page"]) @bp.post("/cache_clear/") @require_admin @@ -65,7 +54,7 @@ def register(url_prefix): html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S")) return sx_response(html) - return redirect(url_for("settings.cache")) + return redirect(url_for("settings.defpage_cache_page")) return bp diff --git a/blog/bp/blog/admin/routes.py b/blog/bp/blog/admin/routes.py index 26bac5c..2465b63 100644 --- a/blog/bp/blog/admin/routes.py +++ b/blog/bp/blog/admin/routes.py @@ -46,27 +46,52 @@ async def _unassigned_tags(session): def register(): bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups") - @bp.get("/") - @require_admin - async def index(): - groups = list( - (await g.s.execute( - select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name) - )).scalars() - ) - unassigned = await _unassigned_tags(g.s) + @bp.before_request + async def _prepare_page_data(): + ep = request.endpoint or "" + if "defpage_tag_groups_page" in ep: + groups = list( + (await g.s.execute( + select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name) + )).scalars() + ) + unassigned = await _unassigned_tags(g.s) + from shared.sx.page import get_template_context + from sx.sx_components import _tag_groups_main_panel_sx + tctx = await get_template_context() + tctx.update({"groups": groups, "unassigned_tags": unassigned}) + g.tag_groups_content = _tag_groups_main_panel_sx(tctx) + elif "defpage_tag_group_edit" in ep: + tag_id = (request.view_args or {}).get("id") + tg = await g.s.get(TagGroup, tag_id) + if not tg: + from quart import abort + abort(404) + assigned_rows = list( + (await g.s.execute( + select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id) + )).scalars() + ) + all_tags = list( + (await g.s.execute( + select(Tag).where( + Tag.deleted_at.is_(None), + (Tag.visibility == "public") | (Tag.visibility.is_(None)), + ).order_by(Tag.name) + )).scalars() + ) + from shared.sx.page import get_template_context + from sx.sx_components import _tag_groups_edit_main_panel_sx + tctx = await get_template_context() + tctx.update({ + "group": tg, + "all_tags": all_tags, + "assigned_tag_ids": set(assigned_rows), + }) + g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx) - ctx = {"groups": groups, "unassigned_tags": unassigned} - - from shared.sx.page import get_template_context - from sx.sx_components import render_tag_groups_page, render_tag_groups_oob - - tctx = await get_template_context() - tctx.update(ctx) - if not is_htmx_request(): - return await make_response(await render_tag_groups_page(tctx)) - else: - return sx_response(await render_tag_groups_oob(tctx)) + from shared.sx.pages import mount_pages + mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"]) @bp.post("/") @require_admin @@ -74,7 +99,7 @@ def register(): form = await request.form name = (form.get("name") or "").strip() if not name: - return redirect(url_for("blog.tag_groups_admin.index")) + return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) slug = _slugify(name) feature_image = (form.get("feature_image") or "").strip() or None @@ -90,55 +115,14 @@ def register(): await g.s.flush() await invalidate_tag_cache("blog") - return redirect(url_for("blog.tag_groups_admin.index")) - - @bp.get("//") - @require_admin - async def edit(id: int): - tg = await g.s.get(TagGroup, id) - if not tg: - return redirect(url_for("blog.tag_groups_admin.index")) - - # Assigned tag IDs for this group - assigned_rows = list( - (await g.s.execute( - select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id) - )).scalars() - ) - assigned_tag_ids = set(assigned_rows) - - # All public, non-deleted tags - all_tags = list( - (await g.s.execute( - select(Tag).where( - Tag.deleted_at.is_(None), - (Tag.visibility == "public") | (Tag.visibility.is_(None)), - ).order_by(Tag.name) - )).scalars() - ) - - ctx = { - "group": tg, - "all_tags": all_tags, - "assigned_tag_ids": assigned_tag_ids, - } - - from shared.sx.page import get_template_context - from sx.sx_components import render_tag_group_edit_page, render_tag_group_edit_oob - - tctx = await get_template_context() - tctx.update(ctx) - if not is_htmx_request(): - return await make_response(await render_tag_group_edit_page(tctx)) - else: - return sx_response(await render_tag_group_edit_oob(tctx)) + return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) @bp.post("//") @require_admin async def save(id: int): tg = await g.s.get(TagGroup, id) if not tg: - return redirect(url_for("blog.tag_groups_admin.index")) + return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) form = await request.form name = (form.get("name") or "").strip() @@ -169,7 +153,7 @@ def register(): await g.s.flush() await invalidate_tag_cache("blog") - return redirect(url_for("blog.tag_groups_admin.edit", id=id)) + return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id)) @bp.post("//delete/") @require_admin @@ -179,6 +163,6 @@ def register(): await g.s.delete(tg) await g.s.flush() await invalidate_tag_cache("blog") - return redirect(url_for("blog.tag_groups_admin.index")) + return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page")) return bp diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index 8e6debc..57fec85 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -51,10 +51,19 @@ def register(url_prefix, title): pass @blogs_bp.before_request - def route(): + async def route(): g.makeqs_factory = makeqs_factory + ep = request.endpoint or "" + if "defpage_new_post" in ep: + from sx.sx_components import render_editor_panel + g.editor_content = render_editor_panel() + elif "defpage_new_page" in ep: + from sx.sx_components import render_editor_panel + g.editor_page_content = render_editor_panel(is_page=True) + + from shared.sx.pages import mount_pages + mount_pages(blogs_bp, "blog", names=["new-post", "new-page"]) - @blogs_bp.context_processor async def inject_root(): return { @@ -215,21 +224,6 @@ def register(url_prefix, title): sx_src = await render_blog_oob(tctx) return sx_response(sx_src) - @blogs_bp.get("/new/") - @require_admin - async def new_post(): - from shared.sx.page import get_template_context - from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel - - tctx = await get_template_context() - tctx["editor_html"] = render_editor_panel() - if not is_htmx_request(): - html = await render_new_post_page(tctx) - return await make_response(html) - else: - sx_src = await render_new_post_oob(tctx) - return sx_response(sx_src) - @blogs_bp.post("/new/") @require_admin async def new_post_save(): @@ -283,25 +277,9 @@ def register(url_prefix, title): await invalidate_tag_cache("blog") # Redirect to the edit page - return redirect(host_url(url_for("blog.post.admin.edit", slug=post.slug))) + return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug))) - @blogs_bp.get("/new-page/") - @require_admin - async def new_page(): - from shared.sx.page import get_template_context - from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel - - tctx = await get_template_context() - tctx["editor_html"] = render_editor_panel(is_page=True) - tctx["is_page"] = True - if not is_htmx_request(): - html = await render_new_post_page(tctx) - return await make_response(html) - else: - sx_src = await render_new_post_oob(tctx) - return sx_response(sx_src) - @blogs_bp.post("/new-page/") @require_admin async def new_page_save(): @@ -357,7 +335,7 @@ def register(url_prefix, title): await invalidate_tag_cache("blog") # Redirect to the page admin - return redirect(host_url(url_for("blog.post.admin.edit", slug=page.slug))) + return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug))) @blogs_bp.get("/drafts/") diff --git a/blog/bp/menu_items/routes.py b/blog/bp/menu_items/routes.py index 2c0ccd6..56d94d4 100644 --- a/blog/bp/menu_items/routes.py +++ b/blog/bp/menu_items/routes.py @@ -23,24 +23,19 @@ def register(): from sx.sx_components import render_menu_items_nav_oob return render_menu_items_nav_oob(menu_items) - @bp.get("/") - @require_admin - async def list_menu_items(): - """List all menu items""" + @bp.before_request + async def _prepare_page_data(): + if "defpage_" not in (request.endpoint or ""): + return menu_items = await get_all_menu_items(g.s) - - from shared.sx.page import get_template_context - from sx.sx_components import render_menu_items_page, render_menu_items_oob - + from sx.sx_components import _menu_items_main_panel_sx tctx = await get_template_context() tctx["menu_items"] = menu_items - if not is_htmx_request(): - html = await render_menu_items_page(tctx) - return await make_response(html) - else: - sx_src = await render_menu_items_oob(tctx) - return sx_response(sx_src) + g.menu_items_content = _menu_items_main_panel_sx(tctx) + + from shared.sx.pages import mount_pages + mount_pages(bp, "blog", names=["menu-items-page"]) @bp.get("/new/") @require_admin diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index a4b86f2..475f664 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -55,51 +55,154 @@ def _post_to_edit_dict(post) -> dict: def register(): bp = Blueprint("admin", __name__, url_prefix='/admin') - - @bp.get("/") - @require_admin - async def admin(slug: str): - from shared.browser.app.utils.htmx import is_htmx_request - from sqlalchemy import select - from shared.models.page_config import PageConfig + @bp.before_request + async def _prepare_page_data(): + ep = request.endpoint or "" + if "defpage_post_admin" in ep: + from sqlalchemy import select + from shared.models.page_config import PageConfig + post = (g.post_data or {}).get("post", {}) + features = {} + sumup_configured = False + sumup_merchant_code = "" + sumup_checkout_prefix = "" + if post.get("is_page"): + pc = (await g.s.execute( + select(PageConfig).where( + PageConfig.container_type == "page", + PageConfig.container_id == post["id"], + ) + )).scalar_one_or_none() + if pc: + features = pc.features or {} + sumup_configured = bool(pc.sumup_api_key) + sumup_merchant_code = pc.sumup_merchant_code or "" + sumup_checkout_prefix = pc.sumup_checkout_prefix or "" + from shared.sx.page import get_template_context + from sx.sx_components import _post_admin_main_panel_sx + tctx = await get_template_context() + tctx.update({ + "features": features, + "sumup_configured": sumup_configured, + "sumup_merchant_code": sumup_merchant_code, + "sumup_checkout_prefix": sumup_checkout_prefix, + }) + g.post_admin_content = _post_admin_main_panel_sx(tctx) - # Load features for page admin (page_configs now lives in db_blog) - post = (g.post_data or {}).get("post", {}) - features = {} - sumup_configured = False - sumup_merchant_code = "" - sumup_checkout_prefix = "" - if post.get("is_page"): - pc = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == "page", - PageConfig.container_id == post["id"], - ) - )).scalar_one_or_none() - if pc: - features = pc.features or {} - sumup_configured = bool(pc.sumup_api_key) - sumup_merchant_code = pc.sumup_merchant_code or "" - sumup_checkout_prefix = pc.sumup_checkout_prefix or "" + elif "defpage_post_data" in ep: + from shared.sx.page import get_template_context + from sx.sx_components import _post_data_content_sx + tctx = await get_template_context() + g.post_data_content = _post_data_content_sx(tctx) - ctx = { - "features": features, - "sumup_configured": sumup_configured, - "sumup_merchant_code": sumup_merchant_code, - "sumup_checkout_prefix": sumup_checkout_prefix, - } + elif "defpage_post_preview" in ep: + from models.ghost_content import Post + from sqlalchemy import select as sa_select + post_id = g.post_data["post"]["id"] + post = (await g.s.execute( + sa_select(Post).where(Post.id == post_id) + )).scalar_one_or_none() + preview_ctx = {} + sx_content = getattr(post, "sx_content", None) or "" + if sx_content: + from shared.sx.prettify import sx_to_pretty_sx + preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content) + lexical_raw = getattr(post, "lexical", None) or "" + if lexical_raw: + from shared.sx.prettify import json_to_pretty_sx + preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw) + if sx_content: + from shared.sx.parser import parse as sx_parse + from shared.sx.html import render as sx_html_render + from shared.sx.jinja_bridge import _COMPONENT_ENV + try: + parsed = sx_parse(sx_content) + preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV)) + except Exception: + preview_ctx["sx_rendered"] = "Error rendering sx" + if lexical_raw: + from bp.blog.ghost.lexical_renderer import render_lexical + try: + preview_ctx["lex_rendered"] = render_lexical(lexical_raw) + except Exception: + preview_ctx["lex_rendered"] = "Error rendering lexical" + from shared.sx.page import get_template_context + from sx.sx_components import _preview_main_panel_sx + tctx = await get_template_context() + tctx.update(preview_ctx) + g.post_preview_content = _preview_main_panel_sx(tctx) - from shared.sx.page import get_template_context - from sx.sx_components import render_post_admin_page, render_post_admin_oob + elif "defpage_post_entries" in ep: + from sqlalchemy import select + from shared.models.calendars import Calendar + from ..services.entry_associations import get_post_entry_ids + post_id = g.post_data["post"]["id"] + associated_entry_ids = await get_post_entry_ids(post_id) + result = await g.s.execute( + select(Calendar) + .where(Calendar.deleted_at.is_(None)) + .order_by(Calendar.name.asc()) + ) + 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 + g.post_entries_content = _post_entries_content_sx(tctx) - tctx = await get_template_context() - tctx.update(ctx) - if not is_htmx_request(): - html = await render_post_admin_page(tctx) - return await make_response(html) - else: - sx_src = await render_post_admin_oob(tctx) - return sx_response(sx_src) + elif "defpage_post_settings" in ep: + from models.ghost_content import Post + from sqlalchemy import select as sa_select + from sqlalchemy.orm import selectinload + post_id = g.post_data["post"]["id"] + 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" + 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 + g.post_settings_content = _post_settings_content_sx(tctx) + + elif "defpage_post_edit" in ep: + 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 + post_id = g.post_data["post"]["id"] + 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", "") + 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 + g.post_edit_content = _post_edit_content_sx(tctx) + + from shared.sx.pages import mount_pages + mount_pages(bp, "blog", names=[ + "post-admin", "post-data", "post-preview", + "post-entries", "post-settings", "post-edit", + ]) @bp.put("/features/") @require_admin @@ -184,77 +287,6 @@ def register(): ) return sx_response(html) - @bp.get("/data/") - @require_admin - async def data(slug: str): - from shared.sx.page import get_template_context - from sx.sx_components import render_post_data_page, render_post_data_oob - - tctx = await get_template_context() - if not is_htmx_request(): - html = await render_post_data_page(tctx) - return await make_response(html) - else: - sx_src = await render_post_data_oob(tctx) - return sx_response(sx_src) - - @bp.get("/preview/") - @require_admin - async def preview(slug: str): - from models.ghost_content import Post - from sqlalchemy import select as sa_select - - from shared.sx.page import get_template_context - from sx.sx_components import render_post_preview_page, render_post_preview_oob - - post_id = g.post_data["post"]["id"] - post = (await g.s.execute( - sa_select(Post).where(Post.id == post_id) - )).scalar_one_or_none() - - # Build the 4 preview views - preview_ctx = {} - - # 1. Prettified sx source - sx_content = getattr(post, "sx_content", None) or "" - if sx_content: - from shared.sx.prettify import sx_to_pretty_sx - preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content) - - # 2. Prettified lexical JSON - lexical_raw = getattr(post, "lexical", None) or "" - if lexical_raw: - from shared.sx.prettify import json_to_pretty_sx - preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw) - - # 3. SX rendered preview - if sx_content: - from shared.sx.parser import parse as sx_parse - from shared.sx.html import render as sx_html_render - from shared.sx.jinja_bridge import _COMPONENT_ENV - try: - parsed = sx_parse(sx_content) - preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV)) - except Exception: - preview_ctx["sx_rendered"] = "Error rendering sx" - - # 4. Lexical rendered preview - if lexical_raw: - from bp.blog.ghost.lexical_renderer import render_lexical - try: - preview_ctx["lex_rendered"] = render_lexical(lexical_raw) - except Exception: - preview_ctx["lex_rendered"] = "Error rendering lexical" - - tctx = await get_template_context() - tctx.update(preview_ctx) - if not is_htmx_request(): - html = await render_post_preview_page(tctx) - return await make_response(html) - else: - sx_src = await render_post_preview_oob(tctx) - return sx_response(sx_src) - @bp.get("/entries/calendar//") @require_admin async def calendar_view(slug: str, calendar_id: int): @@ -330,40 +362,6 @@ def register(): ) return sx_response(html) - @bp.get("/entries/") - @require_admin - async def entries(slug: str): - from ..services.entry_associations import get_post_entry_ids - from shared.models.calendars import Calendar - from sqlalchemy import select - - post_id = g.post_data["post"]["id"] - associated_entry_ids = await get_post_entry_ids(post_id) - - # Load ALL calendars (not just this post's calendars) - result = await g.s.execute( - select(Calendar) - .where(Calendar.deleted_at.is_(None)) - .order_by(Calendar.name.asc()) - ) - all_calendars = result.scalars().all() - - # Load entries and post for each calendar - 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 render_post_entries_page, render_post_entries_oob - - tctx = await get_template_context() - tctx["all_calendars"] = all_calendars - tctx["associated_entry_ids"] = associated_entry_ids - if not is_htmx_request(): - html = await render_post_entries_page(tctx) - return await make_response(html) - else: - sx_src = await render_post_entries_oob(tctx) - return sx_response(sx_src) - @bp.post("/entries//toggle/") @require_admin async def toggle_entry(slug: str, entry_id: int): @@ -416,36 +414,6 @@ def register(): return sx_response(admin_list + nav_entries_html) - @bp.get("/settings/") - @require_post_author - async def settings(slug: str): - from models.ghost_content import Post - from sqlalchemy import select as sa_select - from sqlalchemy.orm import selectinload - - post_id = g.post_data["post"]["id"] - 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" - - from shared.sx.page import get_template_context - from sx.sx_components import render_post_settings_page, render_post_settings_oob - - tctx = await get_template_context() - tctx["ghost_post"] = ghost_post - tctx["save_success"] = save_success - if not is_htmx_request(): - html = await render_post_settings_page(tctx) - return await make_response(html) - else: - sx_src = await render_post_settings_oob(tctx) - return sx_response(sx_src) - @bp.post("/settings/") @require_post_author async def settings_save(slug: str): @@ -500,7 +468,7 @@ def register(): except OptimisticLockError: from urllib.parse import quote return redirect( - host_url(url_for("blog.post.admin.settings", slug=slug)) + host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug)) + "?error=" + quote("Someone else edited this post. Please reload and try again.") ) @@ -511,46 +479,7 @@ def register(): await invalidate_tag_cache("post.post_detail") # Redirect using the (possibly new) slug - return redirect(host_url(url_for("blog.post.admin.settings", slug=post.slug)) + "?saved=1") - - @bp.get("/edit/") - @require_post_author - async def edit(slug: str): - 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 - - post_id = g.post_data["post"]["id"] - 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", "") - - # Newsletters live in db_account — fetch via HTTP - 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 render_post_edit_page, render_post_edit_oob - - tctx = await get_template_context() - tctx["ghost_post"] = ghost_post - tctx["save_success"] = save_success - tctx["save_error"] = save_error - tctx["newsletters"] = newsletters - if not is_htmx_request(): - html = await render_post_edit_page(tctx) - return await make_response(html) - else: - sx_src = await render_post_edit_oob(tctx) - return sx_response(sx_src) + return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1") @bp.post("/edit/") @require_post_author @@ -575,11 +504,11 @@ def register(): try: lexical_doc = json.loads(lexical_raw) except (json.JSONDecodeError, TypeError): - return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content.")) + return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content.")) ok, reason = validate_lexical(lexical_doc) if not ok: - return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason)) + return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason)) # Publish workflow is_admin = bool((g.get("rights") or {}).get("admin")) @@ -615,7 +544,7 @@ def register(): ) except OptimisticLockError: return redirect( - host_url(url_for("blog.post.admin.edit", slug=slug)) + host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Someone else edited this post. Please reload and try again.") ) @@ -631,7 +560,7 @@ def register(): await invalidate_tag_cache("post.post_detail") # Redirect to GET (PRG pattern) — use post.slug in case it changed - redirect_url = host_url(url_for("blog.post.admin.edit", slug=post.slug)) + "?saved=1" + redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1" if publish_requested_msg: redirect_url += "&publish_requested=1" return redirect(redirect_url) diff --git a/blog/bp/snippets/routes.py b/blog/bp/snippets/routes.py index d6828ee..a5c7f22 100644 --- a/blog/bp/snippets/routes.py +++ b/blog/bp/snippets/routes.py @@ -32,25 +32,21 @@ async def _visible_snippets(session): def register(): bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets") - @bp.get("/") - @require_login - async def list_snippets(): - """List snippets visible to the current user.""" + @bp.before_request + async def _prepare_page_data(): + if "defpage_" not in (request.endpoint or ""): + return snippets = await _visible_snippets(g.s) is_admin = g.rights.get("admin") - from shared.sx.page import get_template_context - from sx.sx_components import render_snippets_page, render_snippets_oob - + from sx.sx_components import _snippets_main_panel_sx tctx = await get_template_context() tctx["snippets"] = snippets tctx["is_admin"] = is_admin - if not is_htmx_request(): - html = await render_snippets_page(tctx) - return await make_response(html) - else: - sx_src = await render_snippets_oob(tctx) - return sx_response(sx_src) + g.snippets_content = _snippets_main_panel_sx(tctx) + + from shared.sx.pages import mount_pages + mount_pages(bp, "blog", names=["snippets-page"]) @bp.delete("//") @require_login diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py index b4e49c2..01955ab 100644 --- a/blog/sx/sx_components.py +++ b/blog/sx/sx_components.py @@ -26,6 +26,10 @@ from shared.sx.helpers import ( 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 @@ -76,6 +80,15 @@ def _post_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") - return post_admin_header_sx(ctx, slug, oob=oob, selected=selected) +def _post_admin_mobile_menu(ctx: dict, selected: str = "") -> str: + """Full mobile menu for any post admin page (admin + post + root).""" + slug = (ctx.get("post") or {}).get("slug", "") + return mobile_menu_sx( + post_admin_mobile_nav_sx(ctx, slug, selected), + post_mobile_nav_sx(ctx), + mobile_root_nav_sx(ctx), + ) + # --------------------------------------------------------------------------- # Settings header (root-header-child -> root-settings-header-child) @@ -85,7 +98,7 @@ 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.home") + settings_href = qurl("settings.defpage_settings_home") label_sx = sx_call("blog-admin-label") nav_sx = _settings_nav_sx(ctx) @@ -107,10 +120,10 @@ def _settings_nav_sx(ctx: dict) -> str: parts = [] for endpoint, icon, label in [ - ("menu_items.list_menu_items", "bars", "Menu Items"), - ("snippets.list_snippets", "puzzle-piece", "Snippets"), - ("blog.tag_groups_admin.index", "tags", "Tag Groups"), - ("settings.cache", "refresh", "Cache"), + ("menu_items.defpage_menu_items_page", "bars", "Menu Items"), + ("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"), + ("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"), + ("settings.defpage_cache_page", "refresh", "Cache"), ]: href = qurl(endpoint) parts.append(sx_call("nav-link", @@ -679,7 +692,7 @@ def _post_main_panel_sx(ctx: dict) -> str: 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.edit", slug=slug) + edit_href = qurl("blog.post.admin.defpage_post_edit", slug=slug) edit_sx = sx_call("blog-detail-edit-link", href=edit_href, hx_select=hx_select, ) @@ -951,7 +964,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str: 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.edit", id=g_id) + edit_href = qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id) if g_fi: icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name) @@ -1053,7 +1066,7 @@ async def render_home_page(ctx: dict) -> str: header_rows = "(<> " + root_hdr + " " + post_hdr + ")" content = _home_main_panel_sx(ctx) meta = _post_meta_sx(ctx) - menu = ctx.get("nav_sx", "") or "" + menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx)) return full_page_sx(ctx, header_rows=header_rows, content=content, meta=meta, menu=menu) @@ -1088,9 +1101,8 @@ async def render_blog_oob(ctx: dict) -> str: content = _blog_main_panel_sx(ctx) aside = _blog_aside_sx(ctx) filter_sx = _blog_filter_sx(ctx) - nav = ctx.get("nav_sx", "") or "" return oob_page_sx(oobs=header_oob, content=content, aside=aside, - filter=filter_sx, menu=nav) + filter=filter_sx) async def render_blog_cards(ctx: dict) -> str: @@ -1304,15 +1316,6 @@ async def render_new_post_page(ctx: dict) -> str: return full_page_sx(ctx, header_rows=header_rows, content=content) -async def render_new_post_oob(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - blog_hdr = _blog_header_sx(ctx) - rows = "(<> " + root_hdr + " " + blog_hdr + ")" - header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows) - content = ctx.get("editor_html", "") - return oob_page_sx(oobs=header_oob, content=content) - - # ---- Post detail ---- async def render_post_page(ctx: dict) -> str: @@ -1321,7 +1324,7 @@ async def render_post_page(ctx: dict) -> str: header_rows = "(<> " + root_hdr + " " + post_hdr + ")" content = _post_main_panel_sx(ctx) meta = _post_meta_sx(ctx) - menu = ctx.get("nav_sx", "") or "" + menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx)) return full_page_sx(ctx, header_rows=header_rows, content=content, meta=meta, menu=menu) @@ -1332,35 +1335,14 @@ async def render_post_oob(ctx: dict) -> str: rows = "(<> " + root_hdr + " " + post_hdr + ")" post_oob = _oob_header_sx("root-header-child", "post-header-child", rows) content = _post_main_panel_sx(ctx) - menu = ctx.get("nav_sx", "") or "" + menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx)) oobs = post_oob return oob_page_sx(oobs=oobs, content=content, menu=menu) # ---- Post admin ---- -async def render_post_admin_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - post_hdr = _post_header_sx(ctx) - admin_hdr = _post_admin_header_sx(ctx) - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - content = _post_admin_main_panel_sx(ctx) - menu = ctx.get("nav_sx", "") or "" - return full_page_sx(ctx, header_rows=header_rows, content=content, - menu=menu) - - -async def render_post_admin_oob(ctx: dict) -> str: - post_hdr_oob = _post_header_sx(ctx, oob=True) - admin_oob = _oob_header_sx("post-header-child", "post-admin-header-child", - _post_admin_header_sx(ctx)) - content = _post_admin_main_panel_sx(ctx) - menu = ctx.get("nav_sx", "") or "" - oobs = "(<> " + post_hdr_oob + " " + admin_oob + ")" - return oob_page_sx(oobs=oobs, content=content, menu=menu) - - -# ---- Post data ---- +# =========================================================================== def _post_data_content_sx(ctx: dict) -> str: """Build post data inspector panel natively (replaces _types/post_data/_main_panel.html).""" @@ -1478,22 +1460,7 @@ def _post_data_content_sx(ctx: dict) -> str: return _raw_html_sx(html) -async def render_post_data_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - post_hdr = _post_header_sx(ctx) - admin_hdr = _post_admin_header_sx(ctx, selected="data") - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - content = _post_data_content_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_post_data_oob(ctx: dict) -> str: - admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="data") - content = _post_data_content_sx(ctx) - return oob_page_sx(oobs=admin_hdr_oob, content=content) - - -# ---- Post preview ---- +# =========================================================================== def _preview_main_panel_sx(ctx: dict) -> str: """Build the preview panel with 4 expandable sections.""" @@ -1540,22 +1507,7 @@ def _preview_main_panel_sx(ctx: dict) -> str: return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})")) -async def render_post_preview_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - post_hdr = _post_header_sx(ctx) - admin_hdr = _post_admin_header_sx(ctx, selected="preview") - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - content = _preview_main_panel_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_post_preview_oob(ctx: dict) -> str: - admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="preview") - content = _preview_main_panel_sx(ctx) - return oob_page_sx(oobs=admin_hdr_oob, content=content) - - -# ---- Post entries ---- +# =========================================================================== def _post_entries_content_sx(ctx: dict) -> str: """Build post entries panel natively (replaces _types/post_entries/_main_panel.html).""" @@ -1613,21 +1565,6 @@ def _post_entries_content_sx(ctx: dict) -> str: ) -async def render_post_entries_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - post_hdr = _post_header_sx(ctx) - admin_hdr = _post_admin_header_sx(ctx, selected="entries") - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - content = _post_entries_content_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_post_entries_oob(ctx: dict) -> str: - admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="entries") - content = _post_entries_content_sx(ctx) - return oob_page_sx(oobs=admin_hdr_oob, content=content) - - # ---- Calendar view (for entries browser) ---- def render_calendar_view( @@ -2045,22 +1982,7 @@ def _post_edit_content_sx(ctx: dict) -> str: return _raw_html_sx("".join(parts)) -async def render_post_edit_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - post_hdr = _post_header_sx(ctx) - admin_hdr = _post_admin_header_sx(ctx, selected="edit") - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - content = _post_edit_content_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_post_edit_oob(ctx: dict) -> str: - admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="edit") - content = _post_edit_content_sx(ctx) - return oob_page_sx(oobs=admin_hdr_oob, content=content) - - -# ---- Post settings ---- +# =========================================================================== def _post_settings_content_sx(ctx: dict) -> str: """Build settings form natively (replaces _types/post_settings/_main_panel.html).""" @@ -2195,189 +2117,17 @@ def _post_settings_content_sx(ctx: dict) -> str: return _raw_html_sx(html) -async def render_post_settings_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - post_hdr = _post_header_sx(ctx) - admin_hdr = _post_admin_header_sx(ctx, selected="settings") - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - content = _post_settings_content_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) +# =========================================================================== +# =========================================================================== -async def render_post_settings_oob(ctx: dict) -> str: - admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="settings") - content = _post_settings_content_sx(ctx) - return oob_page_sx(oobs=admin_hdr_oob, content=content) +# =========================================================================== +# =========================================================================== -# ---- Settings home ---- - -async def render_settings_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - settings_hdr = _settings_header_sx(ctx) - header_rows = "(<> " + root_hdr + " " + settings_hdr + ")" - content = _settings_main_panel_sx(ctx) - menu = _settings_nav_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content, - menu=menu) - - -async def render_settings_oob(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - settings_hdr = _settings_header_sx(ctx) - rows = "(<> " + root_hdr + " " + settings_hdr + ")" - header_oob = _oob_header_sx("root-header-child", "root-settings-header-child", rows) - content = _settings_main_panel_sx(ctx) - menu = _settings_nav_sx(ctx) - return oob_page_sx(oobs=header_oob, content=content, menu=menu) - - -# ---- Cache ---- - -async def render_cache_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - settings_hdr = _settings_header_sx(ctx) - from quart import url_for as qurl - cache_hdr = _sub_settings_header_sx( - "cache-row", "cache-header-child", - qurl("settings.cache"), "refresh", "Cache", ctx, - ) - header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + cache_hdr + ")" - content = _cache_main_panel_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_cache_oob(ctx: dict) -> str: - settings_hdr_oob = _settings_header_sx(ctx, oob=True) - from quart import url_for as qurl - cache_hdr = _sub_settings_header_sx( - "cache-row", "cache-header-child", - qurl("settings.cache"), "refresh", "Cache", ctx, - ) - cache_oob = _oob_header_sx("root-settings-header-child", "cache-header-child", - cache_hdr) - content = _cache_main_panel_sx(ctx) - oobs = "(<> " + settings_hdr_oob + " " + cache_oob + ")" - return oob_page_sx(oobs=oobs, content=content) - - -# ---- Snippets ---- - -async def render_snippets_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - settings_hdr = _settings_header_sx(ctx) - from quart import url_for as qurl - snippets_hdr = _sub_settings_header_sx( - "snippets-row", "snippets-header-child", - qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx, - ) - header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + snippets_hdr + ")" - content = _snippets_main_panel_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_snippets_oob(ctx: dict) -> str: - settings_hdr_oob = _settings_header_sx(ctx, oob=True) - from quart import url_for as qurl - snippets_hdr = _sub_settings_header_sx( - "snippets-row", "snippets-header-child", - qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx, - ) - snippets_oob = _oob_header_sx("root-settings-header-child", "snippets-header-child", - snippets_hdr) - content = _snippets_main_panel_sx(ctx) - oobs = "(<> " + settings_hdr_oob + " " + snippets_oob + ")" - return oob_page_sx(oobs=oobs, content=content) - - -# ---- Menu items ---- - -async def render_menu_items_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - settings_hdr = _settings_header_sx(ctx) - from quart import url_for as qurl - mi_hdr = _sub_settings_header_sx( - "menu_items-row", "menu_items-header-child", - qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx, - ) - header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + mi_hdr + ")" - content = _menu_items_main_panel_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_menu_items_oob(ctx: dict) -> str: - settings_hdr_oob = _settings_header_sx(ctx, oob=True) - from quart import url_for as qurl - mi_hdr = _sub_settings_header_sx( - "menu_items-row", "menu_items-header-child", - qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx, - ) - mi_oob = _oob_header_sx("root-settings-header-child", "menu_items-header-child", - mi_hdr) - content = _menu_items_main_panel_sx(ctx) - oobs = "(<> " + settings_hdr_oob + " " + mi_oob + ")" - return oob_page_sx(oobs=oobs, content=content) - - -# ---- Tag groups ---- - -async def render_tag_groups_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - settings_hdr = _settings_header_sx(ctx) - from quart import url_for as qurl - tg_hdr = _sub_settings_header_sx( - "tag-groups-row", "tag-groups-header-child", - qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx, - ) - header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")" - content = _tag_groups_main_panel_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_tag_groups_oob(ctx: dict) -> str: - settings_hdr_oob = _settings_header_sx(ctx, oob=True) - from quart import url_for as qurl - tg_hdr = _sub_settings_header_sx( - "tag-groups-row", "tag-groups-header-child", - qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx, - ) - tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child", - tg_hdr) - content = _tag_groups_main_panel_sx(ctx) - oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")" - return oob_page_sx(oobs=oobs, content=content) - - -# ---- Tag group edit ---- - -async def render_tag_group_edit_page(ctx: dict) -> str: - root_hdr = root_header_sx(ctx) - settings_hdr = _settings_header_sx(ctx) - from quart import url_for as qurl - g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None) - tg_hdr = _sub_settings_header_sx( - "tag-groups-row", "tag-groups-header-child", - qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx, - ) - header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")" - content = _tag_groups_edit_main_panel_sx(ctx) - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_tag_group_edit_oob(ctx: dict) -> str: - settings_hdr_oob = _settings_header_sx(ctx, oob=True) - from quart import url_for as qurl - g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None) - tg_hdr = _sub_settings_header_sx( - "tag-groups-row", "tag-groups-header-child", - qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx, - ) - tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child", - tg_hdr) - content = _tag_groups_edit_main_panel_sx(ctx) - oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")" - return oob_page_sx(oobs=oobs, content=content) +# =========================================================================== +# =========================================================================== # =========================================================================== # PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers diff --git a/blog/sxc/pages/__init__.py b/blog/sxc/pages/__init__.py new file mode 100644 index 0000000..19f1395 --- /dev/null +++ b/blog/sxc/pages/__init__.py @@ -0,0 +1,278 @@ +"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages.""" +from __future__ import annotations + +from typing import Any + + +def setup_blog_pages() -> None: + """Register blog-specific layouts, page helpers, and load page definitions.""" + _register_blog_layouts() + _register_blog_helpers() + _load_blog_page_files() + + +def _load_blog_page_files() -> None: + import os + from shared.sx.pages import load_page_dir + load_page_dir(os.path.dirname(__file__), "blog") + + +# --------------------------------------------------------------------------- +# Layouts +# --------------------------------------------------------------------------- + +def _register_blog_layouts() -> None: + from shared.sx.layouts import register_custom_layout + # :blog — root + blog header (for new-post, new-page) + register_custom_layout("blog", _blog_full, _blog_oob) + # :blog-settings — root + settings header (with settings nav menu) + register_custom_layout("blog-settings", _settings_full, _settings_oob, + mobile_fn=_settings_mobile) + # Sub-settings layouts (root + settings + sub header) + register_custom_layout("blog-cache", _cache_full, _cache_oob) + register_custom_layout("blog-snippets", _snippets_full, _snippets_oob) + register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob) + register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob) + register_custom_layout("blog-tag-group-edit", + _tag_group_edit_full, _tag_group_edit_oob) + + +# --- Blog layout (root + blog header) --- + +def _blog_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx + from sx.sx_components import _blog_header_sx + root_hdr = root_header_sx(ctx) + blog_hdr = _blog_header_sx(ctx) + return "(<> " + root_hdr + " " + blog_hdr + ")" + + +def _blog_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx, oob_header_sx + from sx.sx_components import _blog_header_sx + root_hdr = root_header_sx(ctx) + blog_hdr = _blog_header_sx(ctx) + rows = "(<> " + root_hdr + " " + blog_hdr + ")" + return oob_header_sx("root-header-child", "blog-header-child", rows) + + +# --- Settings layout (root + settings header) --- + +def _settings_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx + from sx.sx_components import _settings_header_sx + root_hdr = root_header_sx(ctx) + settings_hdr = _settings_header_sx(ctx) + return "(<> " + root_hdr + " " + settings_hdr + ")" + + +def _settings_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx, oob_header_sx + from sx.sx_components import _settings_header_sx + root_hdr = root_header_sx(ctx) + settings_hdr = _settings_header_sx(ctx) + rows = "(<> " + root_hdr + " " + settings_hdr + ")" + return oob_header_sx("root-header-child", "root-settings-header-child", rows) + + +def _settings_mobile(ctx: dict, **kw: Any) -> str: + from sx.sx_components import _settings_nav_sx + return _settings_nav_sx(ctx) + + +# --- Sub-settings helpers --- + +def _sub_settings_full(ctx: dict, row_id: str, child_id: str, + endpoint: str, icon: str, label: str) -> str: + from shared.sx.helpers import root_header_sx + from sx.sx_components import _settings_header_sx, _sub_settings_header_sx + from quart import url_for as qurl + root_hdr = root_header_sx(ctx) + settings_hdr = _settings_header_sx(ctx) + sub_hdr = _sub_settings_header_sx(row_id, child_id, + qurl(endpoint), icon, label, ctx) + return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")" + + +def _sub_settings_oob(ctx: dict, row_id: str, child_id: str, + endpoint: str, icon: str, label: str) -> str: + from shared.sx.helpers import oob_header_sx + from sx.sx_components import _settings_header_sx, _sub_settings_header_sx + from quart import url_for as qurl + settings_hdr_oob = _settings_header_sx(ctx, oob=True) + sub_hdr = _sub_settings_header_sx(row_id, child_id, + qurl(endpoint), icon, label, ctx) + sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr) + return "(<> " + settings_hdr_oob + " " + sub_oob + ")" + + +# --- Cache --- + +def _cache_full(ctx: dict, **kw: Any) -> str: + return _sub_settings_full(ctx, "cache-row", "cache-header-child", + "settings.defpage_cache_page", "refresh", "Cache") + + +def _cache_oob(ctx: dict, **kw: Any) -> str: + return _sub_settings_oob(ctx, "cache-row", "cache-header-child", + "settings.defpage_cache_page", "refresh", "Cache") + + +# --- Snippets --- + +def _snippets_full(ctx: dict, **kw: Any) -> str: + return _sub_settings_full(ctx, "snippets-row", "snippets-header-child", + "snippets.defpage_snippets_page", "puzzle-piece", "Snippets") + + +def _snippets_oob(ctx: dict, **kw: Any) -> str: + return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child", + "snippets.defpage_snippets_page", "puzzle-piece", "Snippets") + + +# --- Menu Items --- + +def _menu_items_full(ctx: dict, **kw: Any) -> str: + return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child", + "menu_items.defpage_menu_items_page", "bars", "Menu Items") + + +def _menu_items_oob(ctx: dict, **kw: Any) -> str: + return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child", + "menu_items.defpage_menu_items_page", "bars", "Menu Items") + + +# --- Tag Groups --- + +def _tag_groups_full(ctx: dict, **kw: Any) -> str: + return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child", + "blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") + + +def _tag_groups_oob(ctx: dict, **kw: Any) -> str: + return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child", + "blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups") + + +# --- Tag Group Edit --- + +def _tag_group_edit_full(ctx: dict, **kw: Any) -> str: + from quart import request + g_id = (request.view_args or {}).get("id") + from quart import url_for as qurl + from shared.sx.helpers import root_header_sx + from sx.sx_components import _settings_header_sx, _sub_settings_header_sx + root_hdr = root_header_sx(ctx) + settings_hdr = _settings_header_sx(ctx) + sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", + qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), + "tags", "Tag Groups", ctx) + return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")" + + +def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str: + from quart import request + g_id = (request.view_args or {}).get("id") + from quart import url_for as qurl + from shared.sx.helpers import oob_header_sx + from sx.sx_components import _settings_header_sx, _sub_settings_header_sx + settings_hdr_oob = _settings_header_sx(ctx, oob=True) + sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child", + qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id), + "tags", "Tag Groups", ctx) + sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr) + return "(<> " + settings_hdr_oob + " " + sub_oob + ")" + + +# --------------------------------------------------------------------------- +# Page helpers (sync functions available in .sx defpage expressions) +# --------------------------------------------------------------------------- + +def _register_blog_helpers() -> None: + from shared.sx.pages import register_page_helpers + register_page_helpers("blog", { + "editor-content": _h_editor_content, + "editor-page-content": _h_editor_page_content, + "post-admin-content": _h_post_admin_content, + "post-data-content": _h_post_data_content, + "post-preview-content": _h_post_preview_content, + "post-entries-content": _h_post_entries_content, + "post-settings-content": _h_post_settings_content, + "post-edit-content": _h_post_edit_content, + "settings-content": _h_settings_content, + "cache-content": _h_cache_content, + "snippets-content": _h_snippets_content, + "menu-items-content": _h_menu_items_content, + "tag-groups-content": _h_tag_groups_content, + "tag-group-edit-content": _h_tag_group_edit_content, + }) + + +def _h_editor_content(): + from quart import g + return getattr(g, "editor_content", "") + + +def _h_editor_page_content(): + from quart import g + return getattr(g, "editor_page_content", "") + + +def _h_post_admin_content(): + from quart import g + return getattr(g, "post_admin_content", "") + + +def _h_post_data_content(): + from quart import g + return getattr(g, "post_data_content", "") + + +def _h_post_preview_content(): + from quart import g + return getattr(g, "post_preview_content", "") + + +def _h_post_entries_content(): + from quart import g + return getattr(g, "post_entries_content", "") + + +def _h_post_settings_content(): + from quart import g + return getattr(g, "post_settings_content", "") + + +def _h_post_edit_content(): + from quart import g + return getattr(g, "post_edit_content", "") + + +def _h_settings_content(): + from quart import g + return getattr(g, "settings_content", "") + + +def _h_cache_content(): + from quart import g + return getattr(g, "cache_content", "") + + +def _h_snippets_content(): + from quart import g + return getattr(g, "snippets_content", "") + + +def _h_menu_items_content(): + from quart import g + return getattr(g, "menu_items_content", "") + + +def _h_tag_groups_content(): + from quart import g + return getattr(g, "tag_groups_content", "") + + +def _h_tag_group_edit_content(): + from quart import g + return getattr(g, "tag_group_edit_content", "") diff --git a/blog/sxc/pages/blog.sx b/blog/sxc/pages/blog.sx new file mode 100644 index 0000000..9f87393 --- /dev/null +++ b/blog/sxc/pages/blog.sx @@ -0,0 +1,98 @@ +; Blog app defpage declarations +; Pages kept as Python: home, index, post-detail (cache_page / complex branching) + +; --- New post/page editors --- + +(defpage new-post + :path "/new/" + :auth :admin + :layout :blog + :content (editor-content)) + +(defpage new-page + :path "/new-page/" + :auth :admin + :layout :blog + :content (editor-page-content)) + +; --- Post admin pages (nested under //admin/) --- + +(defpage post-admin + :path "/" + :auth :admin + :layout (:post-admin :selected "admin") + :content (post-admin-content)) + +(defpage post-data + :path "/data/" + :auth :admin + :layout (:post-admin :selected "data") + :content (post-data-content)) + +(defpage post-preview + :path "/preview/" + :auth :admin + :layout (:post-admin :selected "preview") + :content (post-preview-content)) + +(defpage post-entries + :path "/entries/" + :auth :admin + :layout (:post-admin :selected "entries") + :content (post-entries-content)) + +(defpage post-settings + :path "/settings/" + :auth :post_author + :layout (:post-admin :selected "settings") + :content (post-settings-content)) + +(defpage post-edit + :path "/edit/" + :auth :post_author + :layout (:post-admin :selected "edit") + :content (post-edit-content)) + +; --- Settings pages --- + +(defpage settings-home + :path "/" + :auth :admin + :layout :blog-settings + :content (settings-content)) + +(defpage cache-page + :path "/cache/" + :auth :admin + :layout :blog-cache + :content (cache-content)) + +; --- Snippets --- + +(defpage snippets-page + :path "/" + :auth :login + :layout :blog-snippets + :content (snippets-content)) + +; --- Menu Items --- + +(defpage menu-items-page + :path "/" + :auth :admin + :layout :blog-menu-items + :content (menu-items-content)) + +; --- Tag Groups --- + +(defpage tag-groups-page + :path "/" + :auth :admin + :layout :blog-tag-groups + :content (tag-groups-content)) + +(defpage tag-group-edit + :path "//" + :auth :admin + :layout :blog-tag-group-edit + :content (tag-group-edit-content)) diff --git a/blog/templates/_types/blog/_action_buttons.html b/blog/templates/_types/blog/_action_buttons.html index db0745d..fe10ddd 100644 --- a/blog/templates/_types/blog/_action_buttons.html +++ b/blog/templates/_types/blog/_action_buttons.html @@ -1,7 +1,7 @@ {# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
- {% if has_access('blog.new_post') %} - {% set new_href = url_for('blog.new_post')|host %} + {% if has_access('blog.defpage_new_post') %} + {% set new_href = url_for('blog.defpage_new_post')|host %} New Post - {% set new_page_href = url_for('blog.new_page')|host %} + {% set new_page_href = url_for('blog.defpage_new_page')|host %} {% endif %}
- {{ group.name }} diff --git a/blog/templates/_types/blog_drafts/_main_panel.html b/blog/templates/_types/blog_drafts/_main_panel.html index 3875ff1..33ca692 100644 --- a/blog/templates/_types/blog_drafts/_main_panel.html +++ b/blog/templates/_types/blog_drafts/_main_panel.html @@ -2,7 +2,7 @@ -{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} +{% call links.link(url_for('blog.post.admin.defpage_post_entries', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} entries {% endcall %} -{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} +{% call links.link(url_for('blog.post.admin.defpage_post_data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} data {% endcall %} -{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} +{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} edit {% endcall %} -{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} +{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} settings {% endcall %} \ No newline at end of file diff --git a/blog/templates/_types/post/admin/header/_header.html b/blog/templates/_types/post/admin/header/_header.html index 2708e4f..175548a 100644 --- a/blog/templates/_types/post/admin/header/_header.html +++ b/blog/templates/_types/post/admin/header/_header.html @@ -2,7 +2,7 @@ {% macro header_row(oob=False) %} {% call links.menu_row(id='post-admin-row', oob=oob) %} {% call links.link( - url_for('blog.post.admin.admin', slug=post.slug), + url_for('blog.post.admin.defpage_post_admin', slug=post.slug), hx_select_search) %} {{ links.admin() }} {% endcall %} diff --git a/blog/templates/_types/post_data/index.html b/blog/templates/_types/post_data/index.html index 1df67b8..9c5ca96 100644 --- a/blog/templates/_types/post_data/index.html +++ b/blog/templates/_types/post_data/index.html @@ -3,7 +3,7 @@ {% block ___app_title %} {% import 'macros/links.html' as links %} {% call links.menu_row() %} - {% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search) %} + {% call links.link(url_for('blog.post.admin.defpage_post_data', slug=post.slug), hx_select_search) %}
data diff --git a/blog/templates/_types/post_edit/_nav.html b/blog/templates/_types/post_edit/_nav.html index 0b1d08a..a0468f9 100644 --- a/blog/templates/_types/post_edit/_nav.html +++ b/blog/templates/_types/post_edit/_nav.html @@ -1,5 +1,5 @@ {% import 'macros/links.html' as links %} -{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} +{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} settings {% endcall %} diff --git a/blog/templates/_types/post_edit/header/_header.html b/blog/templates/_types/post_edit/header/_header.html index 60e07e7..0590c17 100644 --- a/blog/templates/_types/post_edit/header/_header.html +++ b/blog/templates/_types/post_edit/header/_header.html @@ -1,7 +1,7 @@ {% import 'macros/links.html' as links %} {% macro header_row(oob=False) %} {% call links.menu_row(id='post_edit-row', oob=oob) %} - {% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search) %} + {% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search) %}
edit diff --git a/blog/templates/_types/post_entries/header/_header.html b/blog/templates/_types/post_entries/header/_header.html index 019c000..997a150 100644 --- a/blog/templates/_types/post_entries/header/_header.html +++ b/blog/templates/_types/post_entries/header/_header.html @@ -1,7 +1,7 @@ {% import 'macros/links.html' as links %} {% macro header_row(oob=False) %} {% call links.menu_row(id='post_entries-row', oob=oob) %} - {% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %} + {% call links.link(url_for('blog.post.admin.defpage_post_entries', slug=post.slug), hx_select_search) %}
entries diff --git a/blog/templates/_types/post_settings/_nav.html b/blog/templates/_types/post_settings/_nav.html index a08d80a..01648aa 100644 --- a/blog/templates/_types/post_settings/_nav.html +++ b/blog/templates/_types/post_settings/_nav.html @@ -1,5 +1,5 @@ {% import 'macros/links.html' as links %} -{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} +{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} edit {% endcall %} diff --git a/blog/templates/_types/post_settings/header/_header.html b/blog/templates/_types/post_settings/header/_header.html index ba187fe..b5f9bf4 100644 --- a/blog/templates/_types/post_settings/header/_header.html +++ b/blog/templates/_types/post_settings/header/_header.html @@ -1,7 +1,7 @@ {% import 'macros/links.html' as links %} {% macro header_row(oob=False) %} {% call links.menu_row(id='post_settings-row', oob=oob) %} - {% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search) %} + {% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search) %}
settings diff --git a/blog/templates/_types/root/settings/_nav.html b/blog/templates/_types/root/settings/_nav.html index f9d4420..f6cca5b 100644 --- a/blog/templates/_types/root/settings/_nav.html +++ b/blog/templates/_types/root/settings/_nav.html @@ -1,5 +1,5 @@ {% from 'macros/admin_nav.html' import admin_nav_item %} -{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours) }} -{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours) }} -{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours) }} -{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours) }} +{{ admin_nav_item(url_for('menu_items.defpage_menu_items_page'), 'bars', 'Menu Items', select_colours) }} +{{ admin_nav_item(url_for('snippets.defpage_snippets_page'), 'puzzle-piece', 'Snippets', select_colours) }} +{{ admin_nav_item(url_for('blog.tag_groups_admin.defpage_tag_groups_page'), 'tags', 'Tag Groups', select_colours) }} +{{ admin_nav_item(url_for('settings.defpage_cache_page'), 'refresh', 'Cache', select_colours) }} diff --git a/blog/templates/_types/root/settings/cache/_header.html b/blog/templates/_types/root/settings/cache/_header.html index 64f8535..19047da 100644 --- a/blog/templates/_types/root/settings/cache/_header.html +++ b/blog/templates/_types/root/settings/cache/_header.html @@ -2,7 +2,7 @@ {% macro header_row(oob=False) %} {% call links.menu_row(id='cache-row', oob=oob) %} {% from 'macros/admin_nav.html' import admin_nav_item %} - {{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours, aclass='') }} + {{ admin_nav_item(url_for('settings.defpage_cache_page'), 'refresh', 'Cache', select_colours, aclass='') }} {% call links.desktop_nav() %} {% endcall %} {% endcall %} diff --git a/blog/templates/_types/root/settings/header/_header.html b/blog/templates/_types/root/settings/header/_header.html index 69e7c72..c16968e 100644 --- a/blog/templates/_types/root/settings/header/_header.html +++ b/blog/templates/_types/root/settings/header/_header.html @@ -1,7 +1,7 @@ {% import 'macros/links.html' as links %} {% macro header_row(oob=False) %} {% call links.menu_row(id='root-settings-row', oob=oob) %} - {% call links.link(url_for('settings.home'), hx_select_search) %} + {% call links.link(url_for('settings.defpage_settings_home'), hx_select_search) %} {{ links.admin() }} {% endcall %} {% call links.desktop_nav() %} diff --git a/blog/templates/_types/snippets/header/_header.html b/blog/templates/_types/snippets/header/_header.html index 0882518..9d294c9 100644 --- a/blog/templates/_types/snippets/header/_header.html +++ b/blog/templates/_types/snippets/header/_header.html @@ -2,7 +2,7 @@ {% macro header_row(oob=False) %} {% call links.menu_row(id='snippets-row', oob=oob) %} {% from 'macros/admin_nav.html' import admin_nav_item %} - {{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }} + {{ admin_nav_item(url_for('snippets.defpage_snippets_page'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }} {% call links.desktop_nav() %} {% endcall %} {% endcall %} diff --git a/cart/app.py b/cart/app.py index 8c45fb0..2b50759 100644 --- a/cart/app.py +++ b/cart/app.py @@ -181,6 +181,12 @@ def create_app() -> "Quart": ) g.page_config = _make_page_config(raw_pc) if raw_pc else None + # Setup defpage routes + from sxc.pages import setup_cart_pages + setup_cart_pages() + + from shared.sx.pages import mount_pages + # --- Blueprint registration --- # Static prefixes first, dynamic (page_slug) last @@ -191,22 +197,19 @@ def create_app() -> "Quart": ) # Cart overview at GET / - app.register_blueprint( - register_cart_overview(url_prefix="/"), - url_prefix="/", - ) + overview_bp = register_cart_overview(url_prefix="/") + mount_pages(overview_bp, "cart", names=["cart-overview"]) + app.register_blueprint(overview_bp, url_prefix="/") # Page admin at //admin/ (before page_cart catch-all) - app.register_blueprint( - register_page_admin(), - url_prefix="//admin", - ) + admin_bp = register_page_admin() + mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"]) + app.register_blueprint(admin_bp, url_prefix="//admin") # Page cart at // (dynamic, matched last) - app.register_blueprint( - register_page_cart(url_prefix="/"), - url_prefix="/", - ) + page_cart_bp = register_page_cart(url_prefix="/") + mount_pages(page_cart_bp, "cart", names=["page-cart-view"]) + app.register_blueprint(page_cart_bp, url_prefix="/") return app diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index a1590ce..c0819d0 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint: if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": # Redirect to overview for HTMX - return redirect(url_for("cart_overview.overview")) + return redirect(url_for("cart_overview.defpage_cart_overview")) - return redirect(url_for("cart_overview.overview")) + return redirect(url_for("cart_overview.defpage_cart_overview")) @bp.post("/quantity//") async def update_quantity(product_id: int): @@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint: tickets = await get_ticket_cart_entries(g.s) if not cart and not calendar_entries and not tickets: - return redirect(url_for("cart_overview.overview")) + return redirect(url_for("cart_overview.defpage_cart_overview")) product_total = total(cart) or 0 calendar_amount = calendar_total(calendar_entries) or 0 @@ -145,7 +145,7 @@ def register(url_prefix: str) -> Blueprint: cart_total = product_total + calendar_amount + ticket_amount if cart_total <= 0: - return redirect(url_for("cart_overview.overview")) + return redirect(url_for("cart_overview.defpage_cart_overview")) try: page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) diff --git a/cart/bp/cart/overview_routes.py b/cart/bp/cart/overview_routes.py index 59e6977..a6b0d52 100644 --- a/cart/bp/cart/overview_routes.py +++ b/cart/bp/cart/overview_routes.py @@ -1,31 +1,26 @@ # bp/cart/overview_routes.py — Cart overview (list of page carts) +# GET / handled by defpage. from __future__ import annotations -from quart import Blueprint, render_template, make_response +from quart import Blueprint, g, request -from shared.browser.app.utils.htmx import is_htmx_request -from shared.sx.helpers import sx_response from .services import get_cart_grouped_by_page def register(url_prefix: str) -> Blueprint: bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix) - @bp.get("/") - async def overview(): - from quart import g + @bp.before_request + async def _prepare_page_data(): + """Load overview data for defpage route.""" + endpoint = request.endpoint or "" + if not endpoint.endswith("defpage_cart_overview"): + return from shared.sx.page import get_template_context - from sx.sx_components import render_overview_page, render_overview_oob - + from sx.sx_components import _overview_main_panel_sx page_groups = await get_cart_grouped_by_page(g.s) ctx = await get_template_context() - - if not is_htmx_request(): - html = await render_overview_page(ctx, page_groups) - return await make_response(html) - else: - sx_src = await render_overview_oob(ctx, page_groups) - return sx_response(sx_src) + g.overview_content = _overview_main_panel_sx(page_groups, ctx) return bp diff --git a/cart/bp/cart/page_routes.py b/cart/bp/cart/page_routes.py index 210c8f0..0cbb067 100644 --- a/cart/bp/cart/page_routes.py +++ b/cart/bp/cart/page_routes.py @@ -1,11 +1,10 @@ # bp/cart/page_routes.py — Per-page cart (view + checkout) +# GET / handled by defpage. from __future__ import annotations -from quart import Blueprint, g, redirect, make_response, url_for +from quart import Blueprint, g, redirect, make_response, url_for, request -from shared.browser.app.utils.htmx import is_htmx_request -from shared.sx.helpers import sx_response from shared.infrastructure.actions import call_action from .services import ( total, @@ -20,43 +19,25 @@ from .services import current_cart_identity def register(url_prefix: str) -> Blueprint: bp = Blueprint("page_cart", __name__, url_prefix=url_prefix) - @bp.get("/") - async def page_view(): + @bp.before_request + async def _prepare_page_data(): + """Load page cart data for defpage route.""" + endpoint = request.endpoint or "" + if not endpoint.endswith("defpage_page_cart_view"): + return post = g.page_post cart = await get_cart_for_page(g.s, post.id) cal_entries = await get_calendar_entries_for_page(g.s, post.id) page_tickets = await get_tickets_for_page(g.s, post.id) - ticket_groups = group_tickets(page_tickets) - tpl_ctx = dict( - page_post=post, - page_config=getattr(g, "page_config", None), - cart=cart, - calendar_cart_entries=cal_entries, - ticket_cart_entries=page_tickets, - ticket_groups=ticket_groups, - total=total, - calendar_total=calendar_total, - ticket_total=ticket_total, - ) - from shared.sx.page import get_template_context - from sx.sx_components import render_page_cart_page, render_page_cart_oob - + from sx.sx_components import _page_cart_main_panel_sx ctx = await get_template_context() - if not is_htmx_request(): - html = await render_page_cart_page( - ctx, post, cart, cal_entries, page_tickets, - ticket_groups, total, calendar_total, ticket_total, - ) - return await make_response(html) - else: - sx_src = await render_page_cart_oob( - ctx, post, cart, cal_entries, page_tickets, - ticket_groups, total, calendar_total, ticket_total, - ) - return sx_response(sx_src) + g.page_cart_content = _page_cart_main_panel_sx( + ctx, cart, cal_entries, page_tickets, ticket_groups, + total, calendar_total, ticket_total, + ) @bp.post("/checkout/") async def page_checkout(): @@ -67,7 +48,7 @@ def register(url_prefix: str) -> Blueprint: page_tickets = await get_tickets_for_page(g.s, post.id) if not cart and not cal_entries and not page_tickets: - return redirect(url_for("page_cart.page_view")) + return redirect(url_for("page_cart.defpage_page_cart_view")) product_total_val = total(cart) or 0 calendar_amount = calendar_total(cal_entries) or 0 @@ -75,7 +56,7 @@ def register(url_prefix: str) -> Blueprint: cart_total = product_total_val + calendar_amount + ticket_amount if cart_total <= 0: - return redirect(url_for("page_cart.page_view")) + return redirect(url_for("page_cart.defpage_page_cart_view")) ident = current_cart_identity() diff --git a/cart/bp/page_admin/routes.py b/cart/bp/page_admin/routes.py index c7eca84..d8455fc 100644 --- a/cart/bp/page_admin/routes.py +++ b/cart/bp/page_admin/routes.py @@ -7,42 +7,28 @@ from quart import ( from shared.infrastructure.actions import call_action from shared.infrastructure.data_client import fetch_data from shared.browser.app.authz import require_admin -from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response def register(): bp = Blueprint("page_admin", __name__) - @bp.get("/") - @require_admin - async def admin(**kwargs): - from shared.sx.page import get_template_context - from sx.sx_components import render_cart_admin_page, render_cart_admin_oob - - ctx = await get_template_context() - page_post = getattr(g, "page_post", None) - if not is_htmx_request(): - html = await render_cart_admin_page(ctx, page_post) - return await make_response(html) - else: - sx_src = await render_cart_admin_oob(ctx, page_post) - return sx_response(sx_src) - - @bp.get("/payments/") - @require_admin - async def payments(**kwargs): - from shared.sx.page import get_template_context - from sx.sx_components import render_cart_payments_page, render_cart_payments_oob - - ctx = await get_template_context() - page_post = getattr(g, "page_post", None) - if not is_htmx_request(): - html = await render_cart_payments_page(ctx, page_post) - return await make_response(html) - else: - sx_src = await render_cart_payments_oob(ctx, page_post) - return sx_response(sx_src) + @bp.before_request + async def _prepare_page_data(): + """Pre-render admin content for defpage routes.""" + endpoint = request.endpoint or "" + if request.method != "GET": + return + if endpoint.endswith("defpage_cart_admin"): + from shared.sx.page import get_template_context + from sx.sx_components import _cart_admin_main_panel_sx + ctx = await get_template_context() + g.cart_admin_content = _cart_admin_main_panel_sx(ctx) + elif endpoint.endswith("defpage_cart_payments"): + from shared.sx.page import get_template_context + from sx.sx_components import _cart_payments_main_panel_sx + ctx = await get_template_context() + g.cart_payments_content = _cart_payments_main_panel_sx(ctx) @bp.put("/payments/") @require_admin diff --git a/cart/sx/sx_components.py b/cart/sx/sx_components.py index 78befe2..ec10db3 100644 --- a/cart/sx/sx_components.py +++ b/cart/sx/sx_components.py @@ -587,56 +587,6 @@ def _order_filter_sx(order: Any, list_url: str, recheck_url: str, # Public API: Cart overview # --------------------------------------------------------------------------- -async def render_overview_page(ctx: dict, page_groups: list) -> str: - """Full page: cart overview.""" - main = _overview_main_panel_sx(page_groups, ctx) - hdr = root_header_sx(ctx) - return full_page_sx(ctx, header_rows=hdr, content=main) - - -async def render_overview_oob(ctx: dict, page_groups: list) -> str: - """OOB response for cart overview.""" - main = _overview_main_panel_sx(page_groups, ctx) - oobs = root_header_sx(ctx, oob=True) - return oob_page_sx(oobs=oobs, content=main) - - -# --------------------------------------------------------------------------- -# Public API: Page cart -# --------------------------------------------------------------------------- - -async def render_page_cart_page(ctx: dict, page_post: Any, - cart: list, cal_entries: list, tickets: list, - ticket_groups: list, total_fn: Any, - cal_total_fn: Any, ticket_total_fn: Any) -> str: - """Full page: page-specific cart.""" - main = _page_cart_main_panel_sx(ctx, cart, cal_entries, tickets, ticket_groups, - total_fn, cal_total_fn, ticket_total_fn) - hdr = root_header_sx(ctx) - child = _cart_header_sx(ctx) - page_hdr = _page_cart_header_sx(ctx, page_post) - nested = sx_call( - "header-child-sx", - inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"), - ) - header_rows = "(<> " + hdr + " " + nested + ")" - return full_page_sx(ctx, header_rows=header_rows, content=main) - - -async def render_page_cart_oob(ctx: dict, page_post: Any, - cart: list, cal_entries: list, tickets: list, - ticket_groups: list, total_fn: Any, - cal_total_fn: Any, ticket_total_fn: Any) -> str: - """OOB response for page cart.""" - main = _page_cart_main_panel_sx(ctx, cart, cal_entries, tickets, ticket_groups, - total_fn, cal_total_fn, ticket_total_fn) - child_oob = sx_call("oob-header-sx", - parent_id="cart-header-child", - row=SxExpr(_page_cart_header_sx(ctx, page_post))) - cart_hdr_oob = _cart_header_sx(ctx, oob=True) - root_hdr_oob = root_header_sx(ctx, oob=True) - oobs = "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")" - return oob_page_sx(oobs=oobs, content=main) # --------------------------------------------------------------------------- @@ -821,7 +771,7 @@ def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False, def _cart_admin_main_panel_sx(ctx: dict) -> str: """Admin overview panel -- links to sub-admin pages.""" from quart import url_for - payments_href = url_for("page_admin.payments") + payments_href = url_for("page_admin.defpage_cart_payments") return ( '(div :id "main-panel"' ' (div :class "flex items-center justify-between p-3 border-b"' @@ -851,47 +801,6 @@ def _cart_payments_main_panel_sx(ctx: dict) -> str: checkout_prefix=checkout_prefix) -# --------------------------------------------------------------------------- -# Public API: Cart page admin -# --------------------------------------------------------------------------- - -async def render_cart_admin_page(ctx: dict, page_post: Any) -> str: - """Full page: cart page admin overview.""" - content = _cart_admin_main_panel_sx(ctx) - root_hdr = root_header_sx(ctx) - post_hdr = await _post_header_sx(ctx, page_post) - admin_hdr = _cart_page_admin_header_sx(ctx, page_post) - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str: - """OOB response: cart page admin overview.""" - content = _cart_admin_main_panel_sx(ctx) - oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True) - return oob_page_sx(oobs=oobs, content=content) - - -# --------------------------------------------------------------------------- -# Public API: Cart payments admin -# --------------------------------------------------------------------------- - -async def render_cart_payments_page(ctx: dict, page_post: Any) -> str: - """Full page: payments config.""" - content = _cart_payments_main_panel_sx(ctx) - root_hdr = root_header_sx(ctx) - post_hdr = await _post_header_sx(ctx, page_post) - admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected="payments") - header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" - return full_page_sx(ctx, header_rows=header_rows, content=content) - - -async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str: - """OOB response: payments config.""" - content = _cart_payments_main_panel_sx(ctx) - oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True, selected="payments") - return oob_page_sx(oobs=oobs, content=content) - def render_cart_payments_panel(ctx: dict) -> str: """Render the payments config panel for PUT response.""" diff --git a/cart/sxc/__init__.py b/cart/sxc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cart/sxc/pages/__init__.py b/cart/sxc/pages/__init__.py new file mode 100644 index 0000000..fdac60d --- /dev/null +++ b/cart/sxc/pages/__init__.py @@ -0,0 +1,121 @@ +"""Cart defpage setup — registers layouts, page helpers, and loads .sx pages.""" +from __future__ import annotations + +from typing import Any + + +def setup_cart_pages() -> None: + """Register cart-specific layouts, page helpers, and load page definitions.""" + _register_cart_layouts() + _register_cart_helpers() + _load_cart_page_files() + + +def _load_cart_page_files() -> None: + import os + from shared.sx.pages import load_page_dir + load_page_dir(os.path.dirname(__file__), "cart") + + +# --------------------------------------------------------------------------- +# Layouts +# --------------------------------------------------------------------------- + +def _register_cart_layouts() -> None: + from shared.sx.layouts import register_custom_layout + register_custom_layout("cart-page", _cart_page_full, _cart_page_oob) + register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob) + + +def _cart_page_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx, sx_call, SxExpr + from sx.sx_components import _cart_header_sx, _page_cart_header_sx + + page_post = ctx.get("page_post") + root_hdr = root_header_sx(ctx) + child = _cart_header_sx(ctx) + page_hdr = _page_cart_header_sx(ctx, page_post) + nested = sx_call( + "header-child-sx", + inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"), + ) + return "(<> " + root_hdr + " " + nested + ")" + + +def _cart_page_oob(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx, sx_call, SxExpr + from sx.sx_components import _cart_header_sx, _page_cart_header_sx + + page_post = ctx.get("page_post") + child_oob = sx_call("oob-header-sx", + parent_id="cart-header-child", + row=SxExpr(_page_cart_header_sx(ctx, page_post))) + cart_hdr_oob = _cart_header_sx(ctx, oob=True) + root_hdr_oob = root_header_sx(ctx, oob=True) + return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")" + + +async def _cart_admin_full(ctx: dict, **kw: Any) -> str: + from shared.sx.helpers import root_header_sx + from sx.sx_components import _post_header_sx, _cart_page_admin_header_sx + + page_post = ctx.get("page_post") + selected = kw.get("selected", "") + root_hdr = root_header_sx(ctx) + post_hdr = await _post_header_sx(ctx, page_post) + admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected) + return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")" + + +async def _cart_admin_oob(ctx: dict, **kw: Any) -> str: + from sx.sx_components import _cart_page_admin_header_sx + + page_post = ctx.get("page_post") + selected = kw.get("selected", "") + return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected) + + +# --------------------------------------------------------------------------- +# Page helpers +# --------------------------------------------------------------------------- + +def _register_cart_helpers() -> None: + from shared.sx.pages import register_page_helpers + + register_page_helpers("cart", { + "overview-content": _h_overview_content, + "page-cart-content": _h_page_cart_content, + "cart-admin-content": _h_cart_admin_content, + "cart-payments-content": _h_cart_payments_content, + }) + + +def _h_overview_content(): + from quart import g + page_groups = getattr(g, "overview_page_groups", []) + from sx.sx_components import _overview_main_panel_sx + # _overview_main_panel_sx needs ctx for url helpers — use g-based approach + # The function reads cart_url from ctx, which we can get from template context + from shared.sx.page import get_template_context + import asyncio + # Page helpers are sync — we pre-compute in before_request + return getattr(g, "overview_content", "") + + +def _h_page_cart_content(): + from quart import g + return getattr(g, "page_cart_content", "") + + +def _h_cart_admin_content(): + from sx.sx_components import _cart_admin_main_panel_sx + from shared.sx.page import get_template_context + # Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx + # We can pre-compute in before_request, or use get_template_context_sync-like pattern + from quart import g + return getattr(g, "cart_admin_content", "") + + +def _h_cart_payments_content(): + from quart import g + return getattr(g, "cart_payments_content", "") diff --git a/cart/sxc/pages/cart.sx b/cart/sxc/pages/cart.sx new file mode 100644 index 0000000..23ec20b --- /dev/null +++ b/cart/sxc/pages/cart.sx @@ -0,0 +1,25 @@ +;; Cart app defpage declarations. + +(defpage cart-overview + :path "/" + :auth :public + :layout :root + :content (overview-content)) + +(defpage page-cart-view + :path "/" + :auth :public + :layout :cart-page + :content (page-cart-content)) + +(defpage cart-admin + :path "/" + :auth :admin + :layout :cart-admin + :content (cart-admin-content)) + +(defpage cart-payments + :path "/payments/" + :auth :admin + :layout (:cart-admin :selected "payments") + :content (cart-payments-content)) diff --git a/docs/isomorphic-sx-plan.md b/docs/isomorphic-sx-plan.md new file mode 100644 index 0000000..fcf6569 --- /dev/null +++ b/docs/isomorphic-sx-plan.md @@ -0,0 +1,360 @@ +# Isomorphic SX Architecture Migration Plan + +## Context + +The sx layer already renders full pages client-side — `sx_page()` ships raw sx source + component definitions to the browser, `sx.js` evaluates and renders them. Components are cached in localStorage with a hash-based invalidation protocol (cookie `sx-comp-hash` → server skips sending defs if hash matches). + +**Key insight from the user:** Pages/routes are just components. They belong in the same component registry, cached in localStorage alongside `defcomp` definitions. On navigation, if the client's component hash is current, the server doesn't need to send any s-expression source at all — just data. The client already has the page component cached and renders it locally with fresh data from the API. + +### Target Architecture + +``` +First visit: + Server → component defs (including page components) + page data → client caches defs in localStorage + +Subsequent navigation (same session, hash valid): + Client has page component cached → fetches only JSON data from /api/data/ → renders locally + Server sends: { data: {...} } — zero sx source + +SSR (bots, first paint): + Server evaluates the same page component with direct DB queries → sends rendered HTML + Client hydrates (binds SxEngine handlers, no re-render) +``` + +This is React-like data fetching with an s-expression view layer instead of JSX, and the component transport is a content-addressed cache rather than a JS bundle. + +### Data Delivery Modes + +The data side is not a single pattern — it's a spectrum that can be mixed per page and per fragment: + +**Mode A: Server-bundled data** — Server evaluates the page's `:data` slot, resolves all queries (including cross-service `fetch_data` calls), returns one JSON blob. Fewest round-trips. Server aggregates. + +**Mode B: Client-fetched data** — Client evaluates `:data` slot locally. Each `(query ...)` / `(service ...)` hits the relevant service's `/api/data/` endpoint independently. More round-trips but fully decoupled — each service handles its own data. + +**Mode C: Hybrid** — Server bundles same-service data (direct DB). Client fetches cross-service data in parallel from other services' APIs. Mirrors current server pattern: own-domain = SQLAlchemy, cross-domain = `fetch_data()` HTTP. + +The same spectrum applies to **fragments** (`frag` / `fetch_fragment`): + +- **Server-composed:** Server calls `fetch_fragment()` during page evaluation, bakes result into data bundle or renders inline. +- **Client-composed:** Client's `(frag ...)` primitive fetches from the service's public fragment endpoint. Fragment returns sx source, client renders locally using cached component defs. +- **Mixed:** Stable fragments (nav, auth menu) server-composed; content-specific fragments client-fetched. + +A `(query ...)` or `(frag ...)` call resolves differently depending on execution context (server vs client) but produces the same result. The choice of mode can be per-page, per-fragment, or even per-request. + +## Delivery Order + +``` +Phase 1 (Primitive Parity) ──┐ + ├── Phase 4 (Client Data Primitives) ──┐ +Phase 3 (Public Data API) ───┘ ├── Phase 5 (Data-Only Navigation) +Phase 2 (Server-Side Rendering) ────────────────────────────────────┘ +``` + +Phases 1-3 are independent. Recommended order: **3 → 1 → 2 → 4 → 5** + +--- + +## Phase 1: Primitive Parity + +Align JS and Python primitive sets so the same component source evaluates identically on both sides. + +### 1a: Add missing pure primitives to sx.js + +Add to `PRIMITIVES` in `shared/static/scripts/sx.js`: + +| Primitive | JS implementation | +|-----------|-------------------| +| `clamp` | `Math.max(lo, Math.min(hi, x))` | +| `chunk-every` | partition list into n-size sublists | +| `zip-pairs` | `[[coll[0],coll[1]], [coll[2],coll[3]], ...]` | +| `dissoc` | shallow copy without specified keys | +| `into` | target-type-aware merge | +| `format-date` | minimal strftime translator covering `%Y %m %d %b %B %H %M %S` | +| `parse-int` | `parseInt` with NaN fallback to default | +| `assert` | throw if falsy | + +Fix existing parity gaps: `round` needs optional `ndigits`; `min`/`max` need to accept a single list arg. + +### 1b: Inject `window.__sxConfig` for server-context primitives + +Modify `sx_page()` in `shared/sx/helpers.py` to inject before sx.js: + +```js +window.__sxConfig = { + appUrls: { blog: "https://blog.rose-ash.com", ... }, + assetUrl: "https://static...", + config: { /* public subset */ }, + currentUser: { id, username, display_name, avatar } | null, + relations: [ /* serialized RelationDef list */ ] +}; +``` + +Sources: `ctx` has `blog_url`, `market_url`, etc. `g.user` has user info. `shared/infrastructure/urls.py` has the URL map. + +Add JS primitives reading from `__sxConfig`: `app-url`, `asset-url`, `config`, `current-user`, `relations-from`. + +`url-for` has no JS equivalent — isomorphic code uses `app-url` instead. + +### 1c: Add `defpage` to sx.js evaluator + +Add `defpage` to `SPECIAL_FORMS`. Parse the declaration, store it in `_componentEnv` under `"page:name"` (same registry as components). The page definition includes: name, path pattern, auth requirement, layout spec, and unevaluated AST for data/content/filter/aside/menu slots. + +Since pages live in `_componentEnv`, they're automatically included in the component hash, cached in localStorage, and skipped when the hash matches. No separate `