Migrate all apps to defpage declarative page routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m41s

Replace Python GET page handlers with declarative defpage definitions in .sx
files across all 8 apps (sx docs, orders, account, market, cart, federation,
events, blog). Each app now has sxc/pages/ with setup functions, layout
registrations, page helpers, and .sx defpage declarations.

Core infrastructure: add g I/O primitive, PageDef support for auth/layout/
data/content/filter/aside/menu slots, post_author auth level, and custom
layout registration. Remove ~1400 lines of render_*_page/render_*_oob
boilerplate. Update all endpoint references in routes, sx_components, and
templates to defpage_* naming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 14:52:34 +00:00
parent 5b4cacaf19
commit c243d17eeb
108 changed files with 3598 additions and 2851 deletions

View File

@@ -72,9 +72,19 @@ def create_app() -> "Quart":
app.jinja_loader, 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 --- # --- blueprints ---
app.register_blueprint(register_auth_bp()) 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()) app.register_blueprint(register_fragments())
from bp.actions.routes import register as register_actions from bp.actions.routes import register as register_actions

View File

@@ -1,14 +1,13 @@
"""Account pages blueprint. """Account pages blueprint.
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings). 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 __future__ import annotations
from quart import ( from quart import (
Blueprint, Blueprint,
request, request,
make_response,
redirect, redirect,
g, g,
) )
@@ -20,85 +19,62 @@ from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sx.helpers import sx_response 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="/"): def register(url_prefix="/"):
account_bp = Blueprint("account", __name__, url_prefix=url_prefix) account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
@account_bp.context_processor @account_bp.before_request
async def context(): 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_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}), ("events", "account-nav-item", {}),
("cart", "account-nav-item", {}), ("cart", "account-nav-item", {}),
("artdag", "nav-item", {}), ("artdag", "nav-item", {}),
], required=False) ], 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("/") if request.method != "GET":
async def account(): return
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 not g.get("user"): endpoint = request.endpoint or ""
return redirect(login_url("/"))
ctx = await get_template_context() # Newsletters page — load newsletter data
if not is_htmx_request(): if endpoint.endswith("defpage_newsletters"):
html = await render_account_page(ctx) result = await g.s.execute(
return await make_response(html) select(GhostNewsletter).order_by(GhostNewsletter.name)
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,
) )
) all_newsletters = result.scalars().all()
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = [] sub_result = await g.s.execute(
for nl in all_newsletters: select(UserNewsletter).where(
un = user_subs.get(nl.id) UserNewsletter.user_id == g.user.id,
newsletter_list.append({ )
"newsletter": nl, )
"un": un, user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
"subscribed": un.subscribed if un else False,
})
from shared.sx.page import get_template_context newsletter_list = []
from sx.sx_components import render_newsletters_page, render_newsletters_oob 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() # Fragment page — load fragment from events service
if not is_htmx_request(): elif endpoint.endswith("defpage_fragment_page"):
html = await render_newsletters_page(ctx, newsletter_list) slug = request.view_args.get("slug")
return await make_response(html) if slug and g.get("user"):
else: fragment_html = await fetch_fragment(
sx_src = await render_newsletters_oob(ctx, newsletter_list) "events", "account-page",
return sx_response(sx_src) 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/<int:newsletter_id>/toggle/") @account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int): async def toggle_newsletter(newsletter_id: int):
@@ -128,31 +104,4 @@ def register(url_prefix="/"):
from sx.sx_components import render_newsletter_toggle from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un)) return sx_response(render_newsletter_toggle(un))
# Catch-all for fragment-provided pages — must be last
@account_bp.get("/<slug>/")
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 return account_bp

View File

@@ -12,7 +12,7 @@ from typing import Any
from shared.sx.jinja_bridge import load_service_components from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import ( from shared.sx.helpers import (
call_url, sx_call, SxExpr, 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 # Load account-specific .sx components + handlers at import time
@@ -238,88 +238,8 @@ def _device_approved_content() -> str:
# Public API: Account dashboard # 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: def _fragment_content(frag: object) -> str:

0
account/sxc/__init__.py Normal file
View File

View File

@@ -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)

View File

@@ -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 "/<slug>/"
:auth :login
:layout :account
:content (fragment-content))

View File

@@ -20,6 +20,7 @@ from bp import (
register_data, register_data,
register_actions, register_actions,
) )
from sxc.pages import setup_blog_pages
async def blog_context() -> dict: async def blog_context() -> dict:
@@ -80,6 +81,8 @@ async def blog_context() -> dict:
def create_app() -> "Quart": def create_app() -> "Quart":
from services import register_domain_services from services import register_domain_services
setup_blog_pages()
app = create_base_app( app = create_base_app(
"blog", "blog",
context_fn=blog_context, context_fn=blog_context,

View File

@@ -27,33 +27,22 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings", "base_title": f"{config()['title']} settings",
} }
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def home(): ep = request.endpoint or ""
from shared.sx.page import get_template_context if "defpage_settings_home" in ep:
from sx.sx_components import render_settings_page, render_settings_oob 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() from shared.sx.pages import mount_pages
if not is_htmx_request(): mount_pages(bp, "blog", names=["settings-home", "cache-page"])
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)
@bp.post("/cache_clear/") @bp.post("/cache_clear/")
@require_admin @require_admin
@@ -65,7 +54,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S")) html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html) return sx_response(html)
return redirect(url_for("settings.cache")) return redirect(url_for("settings.defpage_cache_page"))
return bp return bp

View File

@@ -46,27 +46,52 @@ async def _unassigned_tags(session):
def register(): def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups") bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def index(): ep = request.endpoint or ""
groups = list( if "defpage_tag_groups_page" in ep:
(await g.s.execute( groups = list(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name) (await g.s.execute(
)).scalars() select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
) )).scalars()
unassigned = await _unassigned_tags(g.s) )
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.pages import mount_pages
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
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))
@bp.post("/") @bp.post("/")
@require_admin @require_admin
@@ -74,7 +99,7 @@ def register():
form = await request.form form = await request.form
name = (form.get("name") or "").strip() name = (form.get("name") or "").strip()
if not name: 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) slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None feature_image = (form.get("feature_image") or "").strip() or None
@@ -90,55 +115,14 @@ def register():
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") 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"))
@bp.get("/<int:id>/")
@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))
@bp.post("/<int:id>/") @bp.post("/<int:id>/")
@require_admin @require_admin
async def save(id: int): async def save(id: int):
tg = await g.s.get(TagGroup, id) tg = await g.s.get(TagGroup, id)
if not tg: 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 form = await request.form
name = (form.get("name") or "").strip() name = (form.get("name") or "").strip()
@@ -169,7 +153,7 @@ def register():
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") 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("/<int:id>/delete/") @bp.post("/<int:id>/delete/")
@require_admin @require_admin
@@ -179,6 +163,6 @@ def register():
await g.s.delete(tg) await g.s.delete(tg)
await g.s.flush() await g.s.flush()
await invalidate_tag_cache("blog") 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 return bp

View File

@@ -51,10 +51,19 @@ def register(url_prefix, title):
pass pass
@blogs_bp.before_request @blogs_bp.before_request
def route(): async def route():
g.makeqs_factory = makeqs_factory 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 @blogs_bp.context_processor
async def inject_root(): async def inject_root():
return { return {
@@ -215,21 +224,6 @@ def register(url_prefix, title):
sx_src = await render_blog_oob(tctx) sx_src = await render_blog_oob(tctx)
return sx_response(sx_src) 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/") @blogs_bp.post("/new/")
@require_admin @require_admin
async def new_post_save(): async def new_post_save():
@@ -283,25 +277,9 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the edit page # 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/") @blogs_bp.post("/new-page/")
@require_admin @require_admin
async def new_page_save(): async def new_page_save():
@@ -357,7 +335,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the page admin # 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/") @blogs_bp.get("/drafts/")

View File

@@ -23,24 +23,19 @@ def register():
from sx.sx_components import render_menu_items_nav_oob from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items) return render_menu_items_nav_oob(menu_items)
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def list_menu_items(): if "defpage_" not in (request.endpoint or ""):
"""List all menu items""" return
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context 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 = await get_template_context()
tctx["menu_items"] = menu_items tctx["menu_items"] = menu_items
if not is_htmx_request(): g.menu_items_content = _menu_items_main_panel_sx(tctx)
html = await render_menu_items_page(tctx)
return await make_response(html) from shared.sx.pages import mount_pages
else: mount_pages(bp, "blog", names=["menu-items-page"])
sx_src = await render_menu_items_oob(tctx)
return sx_response(sx_src)
@bp.get("/new/") @bp.get("/new/")
@require_admin @require_admin

View File

@@ -55,51 +55,154 @@ def _post_to_edit_dict(post) -> dict:
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
@bp.get("/") async def _prepare_page_data():
@require_admin ep = request.endpoint or ""
async def admin(slug: str): if "defpage_post_admin" in ep:
from shared.browser.app.utils.htmx import is_htmx_request from sqlalchemy import select
from sqlalchemy import select from shared.models.page_config import PageConfig
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) elif "defpage_post_data" in ep:
post = (g.post_data or {}).get("post", {}) from shared.sx.page import get_template_context
features = {} from sx.sx_components import _post_data_content_sx
sumup_configured = False tctx = await get_template_context()
sumup_merchant_code = "" g.post_data_content = _post_data_content_sx(tctx)
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 ""
ctx = { elif "defpage_post_preview" in ep:
"features": features, from models.ghost_content import Post
"sumup_configured": sumup_configured, from sqlalchemy import select as sa_select
"sumup_merchant_code": sumup_merchant_code, post_id = g.post_data["post"]["id"]
"sumup_checkout_prefix": sumup_checkout_prefix, 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"] = "<em>Error rendering sx</em>"
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"] = "<em>Error rendering lexical</em>"
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 elif "defpage_post_entries" in ep:
from sx.sx_components import render_post_admin_page, render_post_admin_oob 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() elif "defpage_post_settings" in ep:
tctx.update(ctx) from models.ghost_content import Post
if not is_htmx_request(): from sqlalchemy import select as sa_select
html = await render_post_admin_page(tctx) from sqlalchemy.orm import selectinload
return await make_response(html) post_id = g.post_data["post"]["id"]
else: post = (await g.s.execute(
sx_src = await render_post_admin_oob(tctx) sa_select(Post)
return sx_response(sx_src) .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/") @bp.put("/features/")
@require_admin @require_admin
@@ -184,77 +287,6 @@ def register():
) )
return sx_response(html) 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"] = "<em>Error rendering sx</em>"
# 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"] = "<em>Error rendering lexical</em>"
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/<int:calendar_id>/") @bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin @require_admin
async def calendar_view(slug: str, calendar_id: int): async def calendar_view(slug: str, calendar_id: int):
@@ -330,40 +362,6 @@ def register():
) )
return sx_response(html) 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/<int:entry_id>/toggle/") @bp.post("/entries/<int:entry_id>/toggle/")
@require_admin @require_admin
async def toggle_entry(slug: str, entry_id: int): async def toggle_entry(slug: str, entry_id: int):
@@ -416,36 +414,6 @@ def register():
return sx_response(admin_list + nav_entries_html) 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/") @bp.post("/settings/")
@require_post_author @require_post_author
async def settings_save(slug: str): async def settings_save(slug: str):
@@ -500,7 +468,7 @@ def register():
except OptimisticLockError: except OptimisticLockError:
from urllib.parse import quote from urllib.parse import quote
return redirect( 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.") + "?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") await invalidate_tag_cache("post.post_detail")
# Redirect using the (possibly new) slug # Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.settings", slug=post.slug)) + "?saved=1") return redirect(host_url(url_for("blog.post.admin.defpage_post_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)
@bp.post("/edit/") @bp.post("/edit/")
@require_post_author @require_post_author
@@ -575,11 +504,11 @@ def register():
try: try:
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): 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) ok, reason = validate_lexical(lexical_doc)
if not ok: 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 # Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin")) is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -615,7 +544,7 @@ def register():
) )
except OptimisticLockError: except OptimisticLockError:
return redirect( 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.") + "?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") await invalidate_tag_cache("post.post_detail")
# Redirect to GET (PRG pattern) — use post.slug in case it changed # 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: if publish_requested_msg:
redirect_url += "&publish_requested=1" redirect_url += "&publish_requested=1"
return redirect(redirect_url) return redirect(redirect_url)

View File

@@ -32,25 +32,21 @@ async def _visible_snippets(session):
def register(): def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets") bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@bp.get("/") @bp.before_request
@require_login async def _prepare_page_data():
async def list_snippets(): if "defpage_" not in (request.endpoint or ""):
"""List snippets visible to the current user.""" return
snippets = await _visible_snippets(g.s) snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin") is_admin = g.rights.get("admin")
from shared.sx.page import get_template_context 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 = await get_template_context()
tctx["snippets"] = snippets tctx["snippets"] = snippets
tctx["is_admin"] = is_admin tctx["is_admin"] = is_admin
if not is_htmx_request(): g.snippets_content = _snippets_main_panel_sx(tctx)
html = await render_snippets_page(tctx)
return await make_response(html) from shared.sx.pages import mount_pages
else: mount_pages(bp, "blog", names=["snippets-page"])
sx_src = await render_snippets_oob(tctx)
return sx_response(sx_src)
@bp.delete("/<int:snippet_id>/") @bp.delete("/<int:snippet_id>/")
@require_login @require_login

View File

@@ -26,6 +26,10 @@ from shared.sx.helpers import (
search_mobile_sx, search_mobile_sx,
search_desktop_sx, search_desktop_sx,
full_page_sx, full_page_sx,
mobile_menu_sx,
mobile_root_nav_sx,
post_mobile_nav_sx,
post_admin_mobile_nav_sx,
) )
# Load blog service .sx component definitions + handler definitions # Load 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) 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) # 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).""" """Settings header row with admin icon and nav links (sx)."""
from quart import url_for as qurl 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") label_sx = sx_call("blog-admin-label")
nav_sx = _settings_nav_sx(ctx) nav_sx = _settings_nav_sx(ctx)
@@ -107,10 +120,10 @@ def _settings_nav_sx(ctx: dict) -> str:
parts = [] parts = []
for endpoint, icon, label in [ for endpoint, icon, label in [
("menu_items.list_menu_items", "bars", "Menu Items"), ("menu_items.defpage_menu_items_page", "bars", "Menu Items"),
("snippets.list_snippets", "puzzle-piece", "Snippets"), ("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"),
("blog.tag_groups_admin.index", "tags", "Tag Groups"), ("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"),
("settings.cache", "refresh", "Cache"), ("settings.defpage_cache_page", "refresh", "Cache"),
]: ]:
href = qurl(endpoint) href = qurl(endpoint)
parts.append(sx_call("nav-link", parts.append(sx_call("nav-link",
@@ -679,7 +692,7 @@ def _post_main_panel_sx(ctx: dict) -> str:
if post.get("status") == "draft": if post.get("status") == "draft":
edit_sx = "" edit_sx = ""
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)): 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", edit_sx = sx_call("blog-detail-edit-link",
href=edit_href, hx_select=hx_select, 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_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) 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: if g_fi:
icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name) 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 + ")" header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
content = _home_main_panel_sx(ctx) content = _home_main_panel_sx(ctx)
meta = _post_meta_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, return full_page_sx(ctx, header_rows=header_rows, content=content,
meta=meta, menu=menu) meta=meta, menu=menu)
@@ -1088,9 +1101,8 @@ async def render_blog_oob(ctx: dict) -> str:
content = _blog_main_panel_sx(ctx) content = _blog_main_panel_sx(ctx)
aside = _blog_aside_sx(ctx) aside = _blog_aside_sx(ctx)
filter_sx = _blog_filter_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, 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: 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) 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 ---- # ---- Post detail ----
async def render_post_page(ctx: dict) -> str: 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 + ")" header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
content = _post_main_panel_sx(ctx) content = _post_main_panel_sx(ctx)
meta = _post_meta_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, return full_page_sx(ctx, header_rows=header_rows, content=content,
meta=meta, menu=menu) meta=meta, menu=menu)
@@ -1332,35 +1335,14 @@ async def render_post_oob(ctx: dict) -> str:
rows = "(<> " + root_hdr + " " + post_hdr + ")" rows = "(<> " + root_hdr + " " + post_hdr + ")"
post_oob = _oob_header_sx("root-header-child", "post-header-child", rows) post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _post_main_panel_sx(ctx) 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 oobs = post_oob
return oob_page_sx(oobs=oobs, content=content, menu=menu) return oob_page_sx(oobs=oobs, content=content, menu=menu)
# ---- Post admin ---- # ---- 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: def _post_data_content_sx(ctx: dict) -> str:
"""Build post data inspector panel natively (replaces _types/post_data/_main_panel.html).""" """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) 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: def _preview_main_panel_sx(ctx: dict) -> str:
"""Build the preview panel with 4 expandable sections.""" """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})")) 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: def _post_entries_content_sx(ctx: dict) -> str:
"""Build post entries panel natively (replaces _types/post_entries/_main_panel.html).""" """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) ---- # ---- Calendar view (for entries browser) ----
def render_calendar_view( def render_calendar_view(
@@ -2045,22 +1982,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
return _raw_html_sx("".join(parts)) 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: def _post_settings_content_sx(ctx: dict) -> str:
"""Build settings form natively (replaces _types/post_settings/_main_panel.html).""" """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) 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 # PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers

278
blog/sxc/pages/__init__.py Normal file
View File

@@ -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", "")

98
blog/sxc/pages/blog.sx Normal file
View File

@@ -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 /<slug>/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 "/<int:id>/"
:auth :admin
:layout :blog-tag-group-edit
:content (tag-group-edit-content))

View File

@@ -1,7 +1,7 @@
{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #} {# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
<div class="flex flex-wrap gap-2 px-4 py-3"> <div class="flex flex-wrap gap-2 px-4 py-3">
{% if has_access('blog.new_post') %} {% if has_access('blog.defpage_new_post') %}
{% set new_href = url_for('blog.new_post')|host %} {% set new_href = url_for('blog.defpage_new_post')|host %}
<a <a
href="{{ new_href }}" href="{{ new_href }}"
sx-get="{{ new_href }}" sx-get="{{ new_href }}"
@@ -14,7 +14,7 @@
> >
<i class="fa fa-plus mr-1"></i> New Post <i class="fa fa-plus mr-1"></i> New Post
</a> </a>
{% set new_page_href = url_for('blog.new_page')|host %} {% set new_page_href = url_for('blog.defpage_new_page')|host %}
<a <a
href="{{ new_page_href }}" href="{{ new_page_href }}"
sx-get="{{ new_page_href }}" sx-get="{{ new_page_href }}"

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-edit-row', oob=oob) %} {% call links.menu_row(id='tag-groups-edit-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %} {% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }} {{ admin_nav_item(url_for('blog.tag_groups_admin.defpage_tag_group_edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }}
{% call links.desktop_nav() %} {% call links.desktop_nav() %}
{% endcall %} {% endcall %}
{% endcall %} {% endcall %}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-row', oob=oob) %} {% call links.menu_row(id='tag-groups-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %} {% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours, aclass='') }} {{ admin_nav_item(url_for('blog.tag_groups_admin.defpage_tag_groups_page'), 'tags', 'Tag Groups', select_colours, aclass='') }}
{% call links.desktop_nav() %} {% call links.desktop_nav() %}
{% endcall %} {% endcall %}
{% endcall %} {% endcall %}

View File

@@ -42,7 +42,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="flex-1"> <div class="flex-1">
<a href="{{ url_for('blog.tag_groups_admin.edit', id=group.id) }}" <a href="{{ url_for('blog.tag_groups_admin.defpage_tag_group_edit', id=group.id) }}"
class="font-medium text-stone-800 hover:underline"> class="font-medium text-stone-800 hover:underline">
{{ group.name }} {{ group.name }}
</a> </a>

View File

@@ -2,7 +2,7 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-stone-800">Drafts</h2> <h2 class="text-2xl font-bold text-stone-800">Drafts</h2>
{% set new_href = url_for('blog.new_post')|host %} {% set new_href = url_for('blog.defpage_new_post')|host %}
<a <a
href="{{ new_href }}" href="{{ new_href }}"
sx-get="{{ new_href }}" sx-get="{{ new_href }}"
@@ -19,7 +19,7 @@
{% if drafts %} {% if drafts %}
<div class="space-y-3"> <div class="space-y-3">
{% for draft in drafts %} {% for draft in drafts %}
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %} {% set edit_href = url_for('blog.post.admin.defpage_post_edit', slug=draft.slug)|host %}
<a <a
href="{{ edit_href }}" href="{{ edit_href }}"
sx-disable sx-disable

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='menu_items-row', oob=oob) %} {% call links.menu_row(id='menu_items-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %} {% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours, aclass='') }} {{ admin_nav_item(url_for('menu_items.defpage_menu_items_page'), 'bars', 'Menu Items', select_colours, aclass='') }}
{% call links.desktop_nav() %} {% call links.desktop_nav() %}
{% endcall %} {% endcall %}
{% endcall %} {% endcall %}

View File

@@ -9,7 +9,7 @@
{% endif %} {% endif %}
{% set is_admin = (g.get("rights") or {}).get("admin") %} {% set is_admin = (g.get("rights") or {}).get("admin") %}
{% if is_admin or (g.user and post.user_id == g.user.id) %} {% if is_admin or (g.user and post.user_id == g.user.id) %}
{% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %} {% set edit_href = url_for('blog.post.admin.defpage_post_edit', slug=post.slug)|host %}
<a <a
href="{{ edit_href }}" href="{{ edit_href }}"
sx-get="{{ edit_href }}" sx-get="{{ edit_href }}"

View File

@@ -8,8 +8,8 @@
{% endif %} {% endif %}
{# Admin link #} {# Admin link #}
{% if post and has_access('blog.post.admin.admin') %} {% if post and has_access('blog.post.admin.defpage_post_admin') %}
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %} {% call links.link(url_for('blog.post.admin.defpage_post_admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i> <i class="fa fa-cog" aria-hidden="true"></i>
{% endcall %} {% endcall %}
{% endif %} {% endif %}

View File

@@ -14,15 +14,15 @@
payments payments
</a> </a>
</div> </div>
{% 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 entries
{% endcall %} {% 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 data
{% endcall %} {% 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 edit
{% endcall %} {% 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 settings
{% endcall %} {% endcall %}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='post-admin-row', oob=oob) %} {% call links.menu_row(id='post-admin-row', oob=oob) %}
{% call links.link( {% 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) %} hx_select_search) %}
{{ links.admin() }} {{ links.admin() }}
{% endcall %} {% endcall %}

View File

@@ -3,7 +3,7 @@
{% block ___app_title %} {% block ___app_title %}
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% call links.menu_row() %} {% 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) %}
<i class="fa fa-database" aria-hidden="true"></i> <i class="fa fa-database" aria-hidden="true"></i>
<div> <div>
data data

View File

@@ -1,5 +1,5 @@
{% import 'macros/links.html' as links %} {% 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) %}
<i class="fa fa-cog" aria-hidden="true"></i> <i class="fa fa-cog" aria-hidden="true"></i>
settings settings
{% endcall %} {% endcall %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='post_edit-row', oob=oob) %} {% 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) %}
<i class="fa fa-pen-to-square" aria-hidden="true"></i> <i class="fa fa-pen-to-square" aria-hidden="true"></i>
<div> <div>
edit edit

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='post_entries-row', oob=oob) %} {% 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) %}
<i class="fa fa-clock" aria-hidden="true"></i> <i class="fa fa-clock" aria-hidden="true"></i>
<div> <div>
entries entries

View File

@@ -1,5 +1,5 @@
{% import 'macros/links.html' as links %} {% 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) %}
<i class="fa fa-pen-to-square" aria-hidden="true"></i> <i class="fa fa-pen-to-square" aria-hidden="true"></i>
edit edit
{% endcall %} {% endcall %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='post_settings-row', oob=oob) %} {% 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) %}
<i class="fa fa-cog" aria-hidden="true"></i> <i class="fa fa-cog" aria-hidden="true"></i>
<div> <div>
settings settings

View File

@@ -1,5 +1,5 @@
{% from 'macros/admin_nav.html' import admin_nav_item %} {% 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('menu_items.defpage_menu_items_page'), 'bars', 'Menu Items', select_colours) }}
{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', 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.index'), 'tags', 'Tag Groups', 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.cache'), 'refresh', 'Cache', select_colours) }} {{ admin_nav_item(url_for('settings.defpage_cache_page'), 'refresh', 'Cache', select_colours) }}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='cache-row', oob=oob) %} {% call links.menu_row(id='cache-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %} {% 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() %} {% call links.desktop_nav() %}
{% endcall %} {% endcall %}
{% endcall %} {% endcall %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='root-settings-row', oob=oob) %} {% 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() }} {{ links.admin() }}
{% endcall %} {% endcall %}
{% call links.desktop_nav() %} {% call links.desktop_nav() %}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='snippets-row', oob=oob) %} {% call links.menu_row(id='snippets-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %} {% 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() %} {% call links.desktop_nav() %}
{% endcall %} {% endcall %}
{% endcall %} {% endcall %}

View File

@@ -181,6 +181,12 @@ def create_app() -> "Quart":
) )
g.page_config = _make_page_config(raw_pc) if raw_pc else None 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 --- # --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last # Static prefixes first, dynamic (page_slug) last
@@ -191,22 +197,19 @@ def create_app() -> "Quart":
) )
# Cart overview at GET / # Cart overview at GET /
app.register_blueprint( overview_bp = register_cart_overview(url_prefix="/")
register_cart_overview(url_prefix="/"), mount_pages(overview_bp, "cart", names=["cart-overview"])
url_prefix="/", app.register_blueprint(overview_bp, url_prefix="/")
)
# Page admin at /<page_slug>/admin/ (before page_cart catch-all) # Page admin at /<page_slug>/admin/ (before page_cart catch-all)
app.register_blueprint( admin_bp = register_page_admin()
register_page_admin(), mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
url_prefix="/<page_slug>/admin", app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
)
# Page cart at /<page_slug>/ (dynamic, matched last) # Page cart at /<page_slug>/ (dynamic, matched last)
app.register_blueprint( page_cart_bp = register_page_cart(url_prefix="/")
register_page_cart(url_prefix="/"), mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
url_prefix="/<page_slug>", app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
)
return app return app

View File

@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX # 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/<int:product_id>/") @bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int): 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) tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets: 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 product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) 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 cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("cart_overview.overview")) return redirect(url_for("cart_overview.defpage_cart_overview"))
try: try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)

View File

@@ -1,31 +1,26 @@
# bp/cart/overview_routes.py — Cart overview (list of page carts) # bp/cart/overview_routes.py — Cart overview (list of page carts)
# GET / handled by defpage.
from __future__ import annotations 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 from .services import get_cart_grouped_by_page
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix) bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@bp.get("/") @bp.before_request
async def overview(): async def _prepare_page_data():
from quart import g """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 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) page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context() ctx = await get_template_context()
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
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)
return bp return bp

View File

@@ -1,11 +1,10 @@
# bp/cart/page_routes.py — Per-page cart (view + checkout) # bp/cart/page_routes.py — Per-page cart (view + checkout)
# GET / handled by defpage.
from __future__ import annotations 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 shared.infrastructure.actions import call_action
from .services import ( from .services import (
total, total,
@@ -20,43 +19,25 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix) bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.get("/") @bp.before_request
async def page_view(): 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 post = g.page_post
cart = await get_cart_for_page(g.s, post.id) cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_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) page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets) 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 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() ctx = await get_template_context()
if not is_htmx_request(): g.page_cart_content = _page_cart_main_panel_sx(
html = await render_page_cart_page( ctx, cart, cal_entries, page_tickets, ticket_groups,
ctx, post, cart, cal_entries, page_tickets, total, calendar_total, ticket_total,
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)
@bp.post("/checkout/") @bp.post("/checkout/")
async def page_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) page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets: 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 product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) 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 cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0: 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() ident = current_cart_identity()

View File

@@ -7,42 +7,28 @@ from quart import (
from shared.infrastructure.actions import call_action from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.browser.app.authz import require_admin 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 from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("page_admin", __name__) bp = Blueprint("page_admin", __name__)
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def admin(**kwargs): """Pre-render admin content for defpage routes."""
from shared.sx.page import get_template_context endpoint = request.endpoint or ""
from sx.sx_components import render_cart_admin_page, render_cart_admin_oob if request.method != "GET":
return
ctx = await get_template_context() if endpoint.endswith("defpage_cart_admin"):
page_post = getattr(g, "page_post", None) from shared.sx.page import get_template_context
if not is_htmx_request(): from sx.sx_components import _cart_admin_main_panel_sx
html = await render_cart_admin_page(ctx, page_post) ctx = await get_template_context()
return await make_response(html) g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
else: elif endpoint.endswith("defpage_cart_payments"):
sx_src = await render_cart_admin_oob(ctx, page_post) from shared.sx.page import get_template_context
return sx_response(sx_src) from sx.sx_components import _cart_payments_main_panel_sx
ctx = await get_template_context()
@bp.get("/payments/") g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
@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.put("/payments/") @bp.put("/payments/")
@require_admin @require_admin

View File

@@ -587,56 +587,6 @@ def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
# Public API: Cart overview # 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: def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Admin overview panel -- links to sub-admin pages.""" """Admin overview panel -- links to sub-admin pages."""
from quart import url_for from quart import url_for
payments_href = url_for("page_admin.payments") payments_href = url_for("page_admin.defpage_cart_payments")
return ( return (
'(div :id "main-panel"' '(div :id "main-panel"'
' (div :class "flex items-center justify-between p-3 border-b"' ' (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) 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: def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response.""" """Render the payments config panel for PUT response."""

0
cart/sxc/__init__.py Normal file
View File

121
cart/sxc/pages/__init__.py Normal file
View File

@@ -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", "")

25
cart/sxc/pages/cart.sx Normal file
View File

@@ -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))

360
docs/isomorphic-sx-plan.md Normal file
View File

@@ -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 `<script data-pages>` block needed — they ship with components.
**Files:** `shared/static/scripts/sx.js`, `shared/sx/helpers.py`
**Verify:** `(format-date "2024-03-15" "%d %b %Y")` produces same output in Python and JS.
---
## Phase 2: Server-Side Rendering (SSR)
Full-page HTML rendering on the server for SEO and first-paint.
### 2a: Add `render_mode` to `execute_page()`
In `shared/sx/pages.py`:
```python
async def execute_page(..., render_mode: str = "client") -> str:
```
When `render_mode="server"`:
- Evaluate all slots via `async_render()` (→ HTML) instead of `async_eval_to_sx()` (→ sx source)
- Layout headers also rendered to HTML
- Pass to new `ssr_page()` instead of `sx_page()`
### 2b: Create `ssr_page()` in helpers.py
Wraps pre-rendered HTML in a document shell:
- Same `<head>` (CSS, CSRF, meta)
- Rendered HTML inline in `<body>` — no `<script type="text/sx" data-mount>`
- Still ships component defs in `<script type="text/sx" data-components>` (client needs them for subsequent navigation)
- Still includes sx.js + body.js (for SPA takeover after first paint)
- Adds `<meta name="sx-ssr" content="true">`
- Injects `__sxConfig` (Phase 1b)
### 2c: SSR trigger
Utility `should_ssr(request)`:
- Bot UA patterns → SSR
- `?_render=server` → SSR (debug)
- `SX-Request: true` header → always client
- Per-page opt-in via `defpage :ssr true`
- Default → client (current behavior)
### 2d: Hydration in sx.js
When sx.js detects `<meta name="sx-ssr">`:
- Skip `Sx.mount()` — DOM already correct
- Run `SxEngine.process(document.body)` — bind sx-get/post handlers
- Run `Sx.hydrate()` — process `[data-sx]` elements
- Load component defs into registry (for subsequent navigations)
**Files:** `shared/sx/pages.py`, `shared/sx/helpers.py`, `shared/static/scripts/sx.js`
**Verify:** Googlebot UA → response has rendered HTML, no `<script data-mount>`. Normal UA → unchanged behavior.
---
## Phase 3: Public Data API
Expose browser-accessible JSON endpoints mirroring internal `/internal/data/` queries.
### 3a: Shared blueprint factory
New `shared/sx/api_data.py`:
```python
def create_public_data_blueprint(service_name: str) -> Blueprint:
"""Session-authed public data blueprint at /api/data/"""
```
Queries registered with auth level: `"public"`, `"login"`, `"admin"`. Validates session (not HMAC). Returns JSON.
### 3b: Extract and share handler implementations
Refactor `bp/data/routes.py` per service — separate query logic from HMAC auth. Same function serves both internal and public paths.
### 3c: Per-service public data blueprints
New `bp/api_data/routes.py` per service:
| Service | Public queries | Auth |
|---------|---------------|------|
| blog | `post-by-slug`, `post-by-id`, `search-posts` | public |
| market | `products-by-ids`, `marketplaces-for-container` | public |
| events | `visible-entries-for-period`, `calendars-for-container`, `entries-for-page` | public |
| cart | `cart-summary`, `cart-items` | login |
| likes | `is-liked`, `liked-slugs` | login |
| account | `newsletters` | public |
Admin queries and write-actions stay internal only.
### 3d: Public fragment endpoints
The existing internal fragment system (`/internal/fragments/<type>`, HMAC-signed) needs public equivalents. Each service already has `create_handler_blueprint()` mounting defhandler fragments. Add a parallel public endpoint:
`GET /api/fragments/<type>?params...` — session-authed, returns `text/sx` (same wire format the client already handles via SxEngine).
This can reuse the same `execute_handler()` machinery — the only difference is auth (session vs HMAC). The blueprint factory in `shared/sx/api_data.py` can handle both data and fragment registration:
```python
bp.register_fragment("container-cards", handler_fn, auth="public")
```
The client's `(frag ...)` primitive then fetches from these public endpoints instead of the HMAC-signed internal ones.
### 3e: Register in app factories
Each service's `app.py` registers the new blueprint.
**Files:** New `shared/sx/api_data.py`, new `{service}/bp/api_data/routes.py` per service, `{service}/app.py`
**Verify:** `curl /api/data/post-by-slug?slug=test` → JSON. `curl /api/fragments/container-cards?type=page&id=1` → sx source. Login-gated query without session → 401.
---
## Phase 4: Client Data Primitives
Async data-fetching in sx.js so I/O primitives work client-side via the public API.
### 4a: Async evaluator — `sxEvalAsync()`
New function in `sx.js` returning a `Promise`. Mirrors `async_eval.py`:
- Literals/symbols → `Promise.resolve(syncValue)`
- I/O primitives (`query`, `service`, `frag`, etc.) → `fetch()` calls to `/api/data/`
- Control flow → sequential async with short-circuit
- `map`/`filter` with I/O → `Promise.all`
### 4b: I/O primitive dispatch
```javascript
IO_PRIMITIVES = {
"query": (svc, name, kw) => fetch(__sxConfig.appUrls[svc] + "/api/data/" + name + "?" + params(kw), {credentials:"include"}).then(r=>r.json()),
"service": (method, kw) => fetch("/api/data/" + method + "?" + params(kw), {credentials:"include"}).then(r=>r.json()),
"frag": (svc, type, kw) => fetch(__sxConfig.appUrls[svc] + "/api/fragments/" + type + "?" + params(kw), {credentials:"include"}).then(r=>r.text()),
"current-user": () => Promise.resolve(__sxConfig.currentUser),
"request-arg": (name) => Promise.resolve(new URLSearchParams(location.search).get(name)),
"request-path": () => Promise.resolve(location.pathname),
"nav-tree": () => fetch("/api/data/nav-tree", {credentials:"include"}).then(r=>r.json()),
};
```
### 4c: Async DOM renderer — `renderDOMAsync()`
Two-pass (avoids restructuring sync renderer):
1. Walk AST, collect I/O call sites with placeholders
2. `Promise.all` to resolve all I/O in parallel
3. Substitute resolved values into AST
4. Call existing sync `renderDOM()` on resolved tree
### 4d: Wire into `Sx.mount()`
Detect I/O nodes. If present → async path. Otherwise → existing sync path (zero overhead for pure components).
**Files:** `shared/static/scripts/sx.js` (major addition)
**Verify:** Page with `(query "blog" "post-by-slug" :slug "test")` in sx source → client fetches `/api/data/post-by-slug?slug=test`, renders result.
---
## Phase 5: Data-Only Navigation
When the client already has page components cached, navigation requires only a data fetch — no sx source from the server.
### 5a: Page components in the registry
`defpage` definitions are already in `_componentEnv` (Phase 1c) and cached in localStorage with the component hash. On navigation, if the hash is valid, the client has all page definitions locally.
Build a `_pageRegistry` mapping URL path patterns → page definitions, populated when `defpage` forms are evaluated. Path patterns (`/posts/<slug>/`) converted to regex matchers for URL matching.
### 5b: Navigation intercept
Extend SxEngine's link click handler:
```
1. Extract URL path from clicked link
2. Match against _pageRegistry
3. If matched:
a. Evaluate :data slot via sxEvalAsync() → parallel API fetches
b. Render :content/:filter/:aside via renderDOMAsync()
c. Morph into existing ~app-body (headers persist, slots update)
d. Push history state
e. Update document title
4. If not matched → existing server fetch (graceful fallback)
```
### 5c: Data delivery — flexible per page
Three modes available (see Context section). The page definition can declare its preference:
```scheme
(defpage blog-post
:path "/posts/<slug>/"
:data-mode :server ; :server (bundled), :client (fetch individually), :hybrid
:data (query "blog" "post-by-slug" :slug slug)
:content (~post-detail post))
```
**Mode :server** — Client sends `SX-Page: blog-post` header on navigation. Server evaluates `:data` slot (all queries, including cross-service), returns single JSON blob:
```python
if request.headers.get("SX-Page"):
data = await evaluate_data_slot(page_def, url_params)
return jsonify(data)
```
**Mode :client** — Client evaluates `:data` slot locally via `sxEvalAsync()`. Each `(query ...)` hits `/api/data/` independently. Each `(frag ...)` hits `/api/fragments/`. No server data endpoint needed.
**Mode :hybrid** — Server bundles own-service data (direct DB). Client fetches cross-service data and fragments in parallel. The `:data` slot is split: server evaluates local queries, returns partial bundle + a manifest of remaining queries. Client resolves the rest.
Default mode can be `:server` (fewest round-trips, simplest). Pages opt into `:client` or `:hybrid` when they want more decoupling or when cross-service data is heavy and benefits from parallel client fetches.
### 5d: Popstate handling
On browser back/forward:
1. Check `_pageRegistry` for popped URL
2. If matched → client render (same as 5b)
3. If not → existing server fetch + morph
### 5e: Graceful fallback
Routes not in `_pageRegistry` fall through to server fetch. Partially migrated apps work — Python-only routes use server fetch, defpage routes get SPA behavior. No big-bang cutover.
**Files:** `shared/static/scripts/sx.js`, `shared/sx/helpers.py`, `shared/sx/pages.py`
**Verify:** Playwright: load page → click link to defpage route → assert no HTML response fetched (only JSON) → content correct → URL updated → back button works.
---
## Summary: The Full Lifecycle
```
1. App startup: Python loads .sx files → defcomp + defpage registered in _COMPONENT_ENV
→ hash computed
2. First visit: Server sends HTML shell + component/page defs + __sxConfig + page sx source
Client evaluates, renders, caches defs in localStorage, sets cookie
3. Return visit: Cookie hash matches → server sends HTML shell with empty <script data-components>
Client loads defs from localStorage → renders page
4. SPA navigation: Client matches URL against _pageRegistry
→ fetches data from /api/data/ (or server data-only endpoint)
→ renders page component locally with fresh data
→ morphs DOM, pushes history
→ zero sx source transferred
5. Bot/SSR: Server detects bot UA → evaluates page server-side with direct DB queries
→ sends rendered HTML + component defs
→ client hydrates (binds handlers, no re-render)
```
## Migration per Service
Each service migrates independently, no coordination needed:
1. Add public data blueprint (Phase 3) — immediate standalone value
2. Convert remaining Jinja routes to `defpage` — already in progress
3. Enable SSR for bots (Phase 2) — per-page opt-in
4. Client data primitives (Phase 4) — global once sx.js updated
5. Data-only navigation (Phase 5) — automatic for any `defpage` route

View File

@@ -78,6 +78,10 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# --- defpage setup ---
from sxc.pages import setup_events_pages
setup_events_pages()
# All events: / — global view across all pages # All events: / — global view across all pages
app.register_blueprint( app.register_blueprint(
register_all_events(), register_all_events(),
@@ -169,11 +173,16 @@ def create_app() -> "Quart":
# Tickets blueprint — user-facing ticket views and QR codes # Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as register_tickets from bp.tickets.routes import register as register_tickets
app.register_blueprint(register_tickets()) tickets_bp = register_tickets()
from shared.sx.pages import mount_pages
mount_pages(tickets_bp, "events", names=["my-tickets", "ticket-detail"])
app.register_blueprint(tickets_bp)
# Ticket admin — check-in interface (admin only) # Ticket admin — check-in interface (admin only)
from bp.ticket_admin.routes import register as register_ticket_admin from bp.ticket_admin.routes import register as register_ticket_admin
app.register_blueprint(register_ticket_admin()) ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
app.register_blueprint(ticket_admin_bp)
# --- oEmbed endpoint --- # --- oEmbed endpoint ---
@app.get("/oembed") @app.get("/oembed")

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, Blueprint, g
) )
@@ -14,23 +14,18 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
# ---------- Pages ----------
@bp.get("/")
@require_admin
async def admin(calendar_slug: str, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_calendar_admin_page, render_calendar_admin_oob from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
g.calendar_admin_content = _calendar_admin_main_panel_html(ctx)
tctx = await get_template_context() from shared.sx.pages import mount_pages
if not is_htmx_request(): mount_pages(bp, "events", names=["calendar-admin"])
html = await render_calendar_admin_page(tctx)
return await make_response(html)
else:
sx_src = await render_calendar_admin_oob(tctx)
return sx_response(sx_src)
@bp.get("/description/") @bp.get("/description/")
@require_admin @require_admin

View File

@@ -1,29 +1,23 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
make_response, Blueprint request, Blueprint, g
) )
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(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
# ---------- Pages ---------- @bp.before_request
@bp.get("/") async def _prepare_page_data():
@require_admin if "defpage_" not in (request.endpoint or ""):
async def admin(entry_id: int, **kwargs): return
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_entry_admin_page, render_entry_admin_oob from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
g.entry_admin_content = _entry_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-admin"])
tctx = await get_template_context()
if not is_htmx_request():
html = await render_entry_admin_page(tctx)
return await make_response(html)
else:
sx_src = await render_entry_admin_oob(tctx)
return sx_response(sx_src)
return bp return bp

View File

@@ -238,20 +238,18 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type, "user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav": container_nav, "container_nav": container_nav,
} }
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def get(entry_id: int, **rest): if "defpage_" not in (request.endpoint or ""):
from shared.browser.app.utils.htmx import is_htmx_request return
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_entry_page, render_entry_oob from sx.sx_components import _entry_main_panel_html, _entry_nav_html
ctx = await get_template_context()
g.entry_content = _entry_main_panel_html(ctx)
g.entry_menu = _entry_nav_html(ctx)
tctx = await get_template_context() from shared.sx.pages import mount_pages
if not is_htmx_request(): mount_pages(bp, "events", names=["entry-detail"])
html = await render_entry_page(tctx)
return await make_response(html, 200)
else:
sx_src = await render_entry_oob(tctx)
return sx_response(sx_src)
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin
@@ -435,10 +433,10 @@ def register():
nav_oob = await get_day_nav_oob(year, month, day) nav_oob = await get_day_nav_oob(year, month, day)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_entry_page from sx.sx_components import _entry_main_panel_html
tctx = await get_template_context() tctx = await get_template_context()
html = await render_entry_page(tctx) html = _entry_main_panel_html(tctx)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)

View File

@@ -1,31 +1,21 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
render_template, make_response, Blueprint request, Blueprint, g
) )
from shared.browser.app.authz import require_admin
from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
# ---------- Pages ----------
@bp.get("/")
@require_admin
async def admin(year: int, month: int, day: int, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.page import get_template_context @bp.before_request
from sx.sx_components import render_day_admin_page, render_day_admin_oob async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from sx.sx_components import _day_admin_main_panel_html
g.day_admin_content = _day_admin_main_panel_html({})
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["day-admin"])
tctx = await get_template_context()
if not is_htmx_request():
html = await render_day_admin_page(tctx)
return await make_response(html)
else:
sx_src = await render_day_admin_oob(tctx)
return sx_response(sx_src)
return bp return bp

View File

@@ -9,9 +9,8 @@ from .services.markets import (
soft_delete as svc_soft_delete, soft_delete as svc_soft_delete,
) )
from shared.browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import clear_cache
from shared.browser.app.authz import require_admin 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 from shared.sx.helpers import sx_response
@@ -22,18 +21,17 @@ def register():
async def inject_root(): async def inject_root():
return {} return {}
@bp.get("/") @bp.before_request
async def home(**kwargs): async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_markets_page, render_markets_oob from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context() ctx = await get_template_context()
if not is_htmx_request(): g.markets_content = _markets_main_panel_html(ctx)
html = await render_markets_page(ctx)
return await make_response(html) from shared.sx.pages import mount_pages
else: mount_pages(bp, "events", names=["events-markets"])
sx_src = await render_markets_oob(ctx)
return sx_response(sx_src)
@bp.post("/new/") @bp.post("/new/")
@require_admin @require_admin

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, make_response, Blueprint, g, jsonify
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -23,33 +23,32 @@ from shared.browser.app.utils import (
parse_time, parse_time,
parse_cost parse_cost
) )
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>') bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
# ---------- Pages ----------
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def get(slot_id: int, **kwargs): if "defpage_" not in (request.endpoint or ""):
slot = await svc_get_slot(g.s, slot_id) return
slot_id = (request.view_args or {}).get("slot_id")
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot: if not slot:
return await make_response("Not found", 404) from quart import abort
abort(404)
from shared.sx.page import get_template_context g.slot = slot
from sx.sx_components import render_slot_page, render_slot_oob calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
g.slot_content = render_slot_main_panel(slot, calendar)
tctx = await get_template_context() @bp.context_processor
if not is_htmx_request(): async def _inject_slot():
html = await render_slot_page(tctx) return {"slot": getattr(g, "slot", None)}
return await make_response(html)
else:
sx_src = await render_slot_oob(tctx)
return sx_response(sx_src)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slot-detail"])
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, Blueprint, g, jsonify
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -19,21 +19,16 @@ from shared.browser.app.utils import (
parse_time, parse_time,
parse_cost parse_cost
) )
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("slots", __name__, url_prefix='/slots') bp = Blueprint("slots", __name__, url_prefix='/slots')
# ---------- Pages ----------
bp.register_blueprint( bp.register_blueprint(
register_slot() register_slot()
) )
@bp.context_processor @bp.context_processor
async def get_slots(): async def get_slots():
calendar = getattr(g, "calendar", None) calendar = getattr(g, "calendar", None)
@@ -43,19 +38,17 @@ def register():
} }
return {"slots": []} return {"slots": []}
@bp.get("/") @bp.before_request
async def get(**kwargs): async def _prepare_page_data():
from shared.sx.page import get_template_context if "defpage_" not in (request.endpoint or ""):
from sx.sx_components import render_slots_page, render_slots_oob return
calendar = getattr(g, "calendar", None)
tctx = await get_template_context() slots = await svc_list_slots(g.s, calendar.id) if calendar else []
if not is_htmx_request(): from sx.sx_components import render_slots_table
html = await render_slots_page(tctx) g.slots_content = render_slots_table(slots, calendar)
return await make_response(html)
else:
sx_src = await render_slots_oob(tctx)
return sx_response(sx_src)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slots-listing"])
@bp.post("/") @bp.post("/")
@require_admin @require_admin

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
import logging import logging
from quart import ( from quart import (
Blueprint, g, request, render_template, make_response, jsonify, Blueprint, g, request, make_response,
) )
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -34,12 +34,10 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets") bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def dashboard(): if "defpage_" not in (request.endpoint or ""):
"""Ticket admin dashboard with QR scanner and recent tickets.""" return
from shared.browser.app.utils.htmx import is_htmx_request
# Get recent tickets # Get recent tickets
result = await g.s.execute( result = await g.s.execute(
select(Ticket) select(Ticket)
@@ -72,15 +70,9 @@ def register() -> Blueprint:
} }
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_ticket_admin_page, render_ticket_admin_oob from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context() ctx = await get_template_context()
if not is_htmx_request(): g.ticket_admin_content = _ticket_admin_main_panel_html(ctx, tickets, stats)
html = await render_ticket_admin_page(ctx, tickets, stats)
return await make_response(html, 200)
else:
sx_src = await render_ticket_admin_oob(ctx, tickets, stats)
return sx_response(sx_src)
@bp.get("/entry/<int:entry_id>/") @bp.get("/entry/<int:entry_id>/")
@require_admin @require_admin

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, make_response, Blueprint, g, jsonify
) )
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
@@ -16,30 +16,37 @@ from .services.ticket import (
from ..ticket_types.services.tickets import ( from ..ticket_types.services.tickets import (
list_ticket_types as svc_list_ticket_types, list_ticket_types as svc_list_ticket_types,
) )
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>') bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def get(ticket_type_id: int, **kwargs): if "defpage_" not in (request.endpoint or ""):
"""View a single ticket type.""" return
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) ticket_type_id = (request.view_args or {}).get("ticket_type_id")
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type: if not ticket_type:
return await make_response("Not found", 404) from quart import abort
from shared.sx.page import get_template_context abort(404)
from sx.sx_components import render_ticket_type_page, render_ticket_type_oob g.ticket_type = ticket_type
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
va = request.view_args or {}
from sx.sx_components import render_ticket_type_main_panel
g.ticket_type_content = render_ticket_type_main_panel(
ticket_type, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
tctx = await get_template_context() @bp.context_processor
if not is_htmx_request(): async def _inject_ticket_type():
html = await render_ticket_type_page(tctx) return {"ticket_type": getattr(g, "ticket_type", None)}
return await make_response(html)
else: from shared.sx.pages import mount_pages
sx_src = await render_ticket_type_oob(tctx) mount_pages(bp, "events", names=["ticket-type-detail"])
return sx_response(sx_src)
@bp.get("/edit/") @bp.get("/edit/")
@require_admin @require_admin

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, Blueprint, g, jsonify
) )
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
@@ -14,7 +14,6 @@ from .services.tickets import (
from ..ticket_type.routes import register as register_ticket_type from ..ticket_type.routes import register as register_ticket_type
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -36,19 +35,22 @@ def register():
} }
return {"ticket_types": []} return {"ticket_types": []}
@bp.get("/") @bp.before_request
async def get(**kwargs): async def _prepare_page_data():
"""List all ticket types for the current entry.""" if "defpage_" not in (request.endpoint or ""):
from shared.sx.page import get_template_context return
from sx.sx_components import render_ticket_types_page, render_ticket_types_oob entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
va = request.view_args or {}
from sx.sx_components import render_ticket_types_table
g.ticket_types_content = render_ticket_types_table(
ticket_types, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
tctx = await get_template_context() from shared.sx.pages import mount_pages
if not is_htmx_request(): mount_pages(bp, "events", names=["ticket-types-listing"])
html = await render_ticket_types_page(tctx)
return await make_response(html)
else:
sx_src = await render_ticket_types_oob(tctx)
return sx_response(sx_src)
@bp.post("/") @bp.post("/")
@require_admin @require_admin

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
import logging import logging
from quart import ( from quart import (
Blueprint, g, request, render_template, make_response, Blueprint, g, request, make_response,
) )
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -39,59 +39,43 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets") bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/") @bp.before_request
async def my_tickets(): async def _prepare_page_data():
"""List all tickets for the current user/session.""" ep = request.endpoint or ""
from shared.browser.app.utils.htmx import is_htmx_request if "defpage_my_tickets" in ep:
ident = current_cart_identity()
ident = current_cart_identity() tickets = await get_user_tickets(
tickets = await get_user_tickets( g.s,
g.s, user_id=ident["user_id"],
user_id=ident["user_id"], session_id=ident["session_id"],
session_id=ident["session_id"], )
) from shared.sx.page import get_template_context
from sx.sx_components import _tickets_main_panel_html
from shared.sx.page import get_template_context ctx = await get_template_context()
from sx.sx_components import render_tickets_page, render_tickets_oob g.tickets_content = _tickets_main_panel_html(ctx, tickets)
elif "defpage_ticket_detail" in ep:
ctx = await get_template_context() code = (request.view_args or {}).get("code")
if not is_htmx_request(): ticket = await get_ticket_by_code(g.s, code) if code else None
html = await render_tickets_page(ctx, tickets) if not ticket:
return await make_response(html, 200) from quart import abort
else: abort(404)
sx_src = await render_tickets_oob(ctx, tickets) # Verify ownership
return sx_response(sx_src) ident = current_cart_identity()
if ident["user_id"] is not None:
@bp.get("/<code>/") if ticket.user_id != ident["user_id"]:
async def ticket_detail(code: str): from quart import abort
"""View a single ticket with QR code.""" abort(404)
from shared.browser.app.utils.htmx import is_htmx_request elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
ticket = await get_ticket_by_code(g.s, code) from quart import abort
if not ticket: abort(404)
return await make_response("Ticket not found", 404) else:
from quart import abort
# Verify ownership abort(404)
ident = current_cart_identity() from shared.sx.page import get_template_context
if ident["user_id"] is not None: from sx.sx_components import _ticket_detail_panel_html
if ticket.user_id != ident["user_id"]: ctx = await get_template_context()
return await make_response("Ticket not found", 404) g.ticket_detail_content = _ticket_detail_panel_html(ctx, ticket)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
return await make_response("Ticket not found", 404)
else:
return await make_response("Ticket not found", 404)
from shared.sx.page import get_template_context
from sx.sx_components import render_ticket_detail_page, render_ticket_detail_oob
ctx = await get_template_context()
if not is_htmx_request():
html = await render_ticket_detail_page(ctx, ticket)
return await make_response(html, 200)
else:
sx_src = await render_ticket_detail_oob(ctx, ticket)
return sx_response(sx_src)
@bp.post("/buy/") @bp.post("/buy/")
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")

View File

@@ -191,11 +191,11 @@ def _calendar_nav_sx(ctx: dict) -> str:
select_colours = ctx.get("select_colours", "") select_colours = ctx.get("select_colours", "")
parts = [] parts = []
slots_href = url_for("calendar.slots.get", calendar_slug=cal_slug) slots_href = url_for("calendar.slots.defpage_slots_listing", calendar_slug=cal_slug)
parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock", parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock",
label="Slots", select_colours=select_colours)) label="Slots", select_colours=select_colours))
if is_admin: if is_admin:
admin_href = url_for("calendar.admin.admin", calendar_slug=cal_slug) admin_href = url_for("calendar.admin.defpage_calendar_admin", calendar_slug=cal_slug)
parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog", parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog",
select_colours=select_colours)) select_colours=select_colours))
return "".join(parts) return "".join(parts)
@@ -319,7 +319,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
nav_parts = [] nav_parts = []
if cal_slug: if cal_slug:
for endpoint, label in [ for endpoint, label in [
("calendar.slots.get", "slots"), ("calendar.slots.defpage_slots_listing", "slots"),
("calendar.admin.calendar_description_edit", "description"), ("calendar.admin.calendar_description_edit", "description"),
]: ]:
href = url_for(endpoint, calendar_slug=cal_slug) href = url_for(endpoint, calendar_slug=cal_slug)
@@ -339,7 +339,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str: def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the markets section header row.""" """Build the markets section header row."""
from quart import url_for from quart import url_for
link_href = url_for("markets.home") link_href = url_for("markets.defpage_events_markets")
return sx_call("menu-row-sx", id="markets-row", level=3, return sx_call("menu-row-sx", id="markets-row", level=3,
link_href=link_href, link_href=link_href,
link_label_content=SxExpr(sx_call("events-markets-label")), link_label_content=SxExpr(sx_call("events-markets-label")),
@@ -594,7 +594,7 @@ def _day_row_html(ctx: dict, entry) -> str:
# Slot/Time # Slot/Time
slot = getattr(entry, "slot", None) slot = getattr(entry, "slot", None)
if slot: if slot:
slot_href = url_for("calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=slot.id) slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
slot_html = sx_call("events-day-row-slot", slot_html = sx_call("events-day-row-slot",
@@ -774,7 +774,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
ticket_cards = [] ticket_cards = []
if tickets: if tickets:
for ticket in tickets: for ticket in tickets:
href = url_for("tickets.ticket_detail", code=ticket.code) href = url_for("tickets.defpage_ticket_detail", code=ticket.code)
entry = getattr(ticket, "entry", None) entry = getattr(ticket, "entry", None)
entry_name = entry.name if entry else "Unknown event" entry_name = entry.name if entry else "Unknown event"
tt = getattr(ticket, "ticket_type", None) tt = getattr(ticket, "ticket_type", None)
@@ -819,7 +819,7 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str:
bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"} bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"}
header_bg = bg_map.get(state, "bg-stone-50") header_bg = bg_map.get(state, "bg-stone-50")
entry_name = entry.name if entry else "Ticket" entry_name = entry.name if entry else "Ticket"
back_href = url_for("tickets.my_tickets") back_href = url_for("tickets.defpage_my_tickets")
# Badge with larger sizing # Badge with larger sizing
badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm') badge = _ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')
@@ -1400,42 +1400,7 @@ async def render_day_oob(ctx: dict) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Day admin # Calendar admin helper
# ---------------------------------------------------------------------------
async def render_day_admin_page(ctx: dict) -> str:
"""Full page: day admin."""
content = _day_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _day_admin_header_sx(ctx))
hdr = root_hdr + post_hdr + header_child_sx(child)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_day_admin_oob(ctx: dict) -> str:
"""OOB response: day admin."""
content = _day_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("day-header-child", "day-admin-header-child",
_day_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"day-admin-row", "day-admin-header-child")
return oob_page_sx(oobs=oobs, content=content)
# ---------------------------------------------------------------------------
# Calendar admin
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _events_post_admin_header_sx(ctx: dict, *, oob: bool = False, def _events_post_admin_header_sx(ctx: dict, *, oob: bool = False,
@@ -1445,140 +1410,6 @@ def _events_post_admin_header_sx(ctx: dict, *, oob: bool = False,
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected) return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
async def render_calendar_admin_page(ctx: dict) -> str:
"""Full page: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = admin_hdr + _calendar_header_sx(ctx) + _calendar_admin_header_sx(ctx)
hdr = root_hdr + post_hdr + header_child_sx(child)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_calendar_admin_oob(ctx: dict) -> str:
"""OOB response: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-header-child", "calendar-admin-header-child",
_calendar_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")
return oob_page_sx(oobs=oobs, content=content)
# ---------------------------------------------------------------------------
# Slots
# ---------------------------------------------------------------------------
async def render_slots_page(ctx: dict) -> str:
"""Full page: slots listing."""
from quart import g
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = admin_hdr + _calendar_header_sx(ctx) + _calendar_admin_header_sx(ctx)
hdr = root_hdr + post_hdr + header_child_sx(child)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_slots_oob(ctx: dict) -> str:
"""OOB response: slots listing."""
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")
return oob_page_sx(oobs=oobs, content=content)
# ---------------------------------------------------------------------------
# Tickets
# ---------------------------------------------------------------------------
async def render_tickets_page(ctx: dict, tickets: list) -> str:
"""Full page: my tickets."""
content = _tickets_main_panel_html(ctx, tickets)
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_tickets_oob(ctx: dict, tickets: list) -> str:
"""OOB response: my tickets."""
content = _tickets_main_panel_html(ctx, tickets)
return oob_page_sx(content=content)
async def render_ticket_detail_page(ctx: dict, ticket) -> str:
"""Full page: ticket detail with QR."""
content = _ticket_detail_panel_html(ctx, ticket)
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_ticket_detail_oob(ctx: dict, ticket) -> str:
"""OOB response: ticket detail."""
content = _ticket_detail_panel_html(ctx, ticket)
return oob_page_sx(content=content)
# ---------------------------------------------------------------------------
# Ticket admin
# ---------------------------------------------------------------------------
async def render_ticket_admin_page(ctx: dict, tickets: list, stats: dict) -> str:
"""Full page: ticket admin dashboard."""
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_ticket_admin_oob(ctx: dict, tickets: list, stats: dict) -> str:
"""OOB response: ticket admin dashboard."""
content = _ticket_admin_main_panel_html(ctx, tickets, stats)
return oob_page_sx(content=content)
# ---------------------------------------------------------------------------
# Markets
# ---------------------------------------------------------------------------
async def render_markets_page(ctx: dict) -> str:
"""Full page: markets listing."""
content = _markets_main_panel_html(ctx)
hdr = root_header_sx(ctx)
child = _post_header_sx(ctx) + _markets_header_sx(ctx)
hdr += header_child_sx(child)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_markets_oob(ctx: dict) -> str:
"""OOB response: markets listing."""
content = _markets_main_panel_html(ctx)
oobs = _post_header_sx(ctx, oob=True)
oobs += oob_header_sx("post-header-child", "markets-header-child",
_markets_header_sx(ctx))
return oob_page_sx(oobs=oobs, content=content)
# =========================================================================== # ===========================================================================
# POST / PUT / DELETE response components # POST / PUT / DELETE response components
# =========================================================================== # ===========================================================================
@@ -1939,36 +1770,6 @@ def _entry_nav_html(ctx: dict) -> str:
return "".join(parts) return "".join(parts)
# ---------------------------------------------------------------------------
# Entry page / OOB rendering
# ---------------------------------------------------------------------------
async def render_entry_page(ctx: dict) -> str:
"""Full page: entry detail."""
content = _entry_main_panel_html(ctx)
hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx)
+ _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _entry_header_html(ctx))
hdr += header_child_sx(child)
nav_html = _entry_nav_html(ctx)
return full_page_sx(ctx, header_rows=hdr, content=content, menu=nav_html)
async def render_entry_oob(ctx: dict) -> str:
"""OOB response: entry detail."""
content = _entry_main_panel_html(ctx)
oobs = _day_header_sx(ctx, oob=True)
oobs += oob_header_sx("day-header-child", "entry-header-child",
_entry_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child")
nav_html = _entry_nav_html(ctx)
return oob_page_sx(oobs=oobs, content=content, menu=nav_html)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Entry optioned (confirm/decline/provisional response) # Entry optioned (confirm/decline/provisional response)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2364,7 +2165,7 @@ def render_slots_table(slots, calendar) -> str:
rows_html = "" rows_html = ""
if slots: if slots:
for s in slots: for s in slots:
slot_href = url_for("calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=s.id) slot_href = url_for("calendar.slots.slot.defpage_slot_detail", calendar_slug=cal_slug, slot_id=s.id)
del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id) del_url = url_for("calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id)
desc = getattr(s, "description", "") or "" desc = getattr(s, "description", "") or ""
@@ -2508,7 +2309,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
tickets_html = "" tickets_html = ""
for ticket in created_tickets: for ticket in created_tickets:
href = url_for("tickets.ticket_detail", code=ticket.code) href = url_for("tickets.defpage_ticket_detail", code=ticket.code)
tickets_html += sx_call("events-buy-result-ticket", tickets_html += sx_call("events-buy-result-ticket",
href=href, code_short=ticket.code[:12] + "...") href=href, code_short=ticket.code[:12] + "...")
@@ -2518,7 +2319,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
remaining_html = sx_call("events-buy-result-remaining", remaining_html = sx_call("events-buy-result-remaining",
text=f"{remaining} ticket{r_suffix} remaining") text=f"{remaining} ticket{r_suffix} remaining")
my_href = url_for("tickets.my_tickets") my_href = url_for("tickets.defpage_my_tickets")
return cart_html + sx_call("events-buy-result", return cart_html + sx_call("events-buy-result",
entry_id=str(entry.id), entry_id=str(entry.id),
@@ -2610,7 +2411,7 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket
return _adj_form(1, sx_call("events-adjust-cart-plus"), return _adj_form(1, sx_call("events-adjust-cart-plus"),
extra_cls="flex items-center") extra_cls="flex items-center")
my_tickets_href = url_for("tickets.my_tickets") my_tickets_href = url_for("tickets.defpage_my_tickets")
minus = _adj_form(count - 1, sx_call("events-adjust-minus")) minus = _adj_form(count - 1, sx_call("events-adjust-minus"))
cart_icon = sx_call("events-adjust-cart-icon", cart_icon = sx_call("events-adjust-cart-icon",
href=my_tickets_href, count=str(count)) href=my_tickets_href, count=str(count))
@@ -2960,40 +2761,6 @@ def _entry_admin_main_panel_html(ctx: dict) -> str:
is_selected=False) is_selected=False)
async def render_entry_admin_page(ctx: dict) -> str:
"""Full page: entry admin."""
content = _entry_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx))
hdr = root_hdr + post_hdr + header_child_sx(child)
nav_html = sx_call("events-admin-placeholder-nav")
return full_page_sx(ctx, header_rows=hdr, content=content, menu=nav_html)
async def render_entry_admin_oob(ctx: dict) -> str:
"""OOB response: entry admin."""
content = _entry_admin_main_panel_html(ctx)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _entry_header_html(ctx, oob=True))
oobs += oob_header_sx("entry-header-child", "entry-admin-header-child",
_entry_admin_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child",
"entry-admin-row", "entry-admin-header-child")
nav_html = sx_call("events-admin-placeholder-nav")
return oob_page_sx(oobs=oobs, content=content, menu=nav_html)
# =========================================================================== # ===========================================================================
# Slot page / OOB (extends slots) # Slot page / OOB (extends slots)
# =========================================================================== # ===========================================================================
@@ -3027,45 +2794,6 @@ def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
child_id="slot-header-child", oob=oob) child_id="slot-header-child", oob=oob)
async def render_slot_page(ctx: dict) -> str:
"""Full page: slot detail (extends slots page)."""
slot = ctx.get("slot")
calendar = ctx.get("calendar")
if not slot or not calendar:
return ""
content = render_slot_main_panel(slot, calendar)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _calendar_admin_header_sx(ctx)
+ _slot_header_html(ctx))
hdr = root_hdr + post_hdr + header_child_sx(child)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_slot_oob(ctx: dict) -> str:
"""OOB response: slot detail."""
slot = ctx.get("slot")
calendar = ctx.get("calendar")
if not slot or not calendar:
return ""
content = render_slot_main_panel(slot, calendar)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-admin-header-child", "slot-header-child",
_slot_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child",
"slot-row", "slot-header-child")
return oob_page_sx(oobs=oobs, content=content)
# =========================================================================== # ===========================================================================
# Slot edit form # Slot edit form
# =========================================================================== # ===========================================================================
@@ -3243,40 +2971,6 @@ def _ticket_types_header_html(ctx: dict, *, oob: bool = False) -> str:
nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child", oob=oob) nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child", oob=oob)
async def render_ticket_types_page(ctx: dict) -> str:
"""Full page: ticket types listing (extends entry admin)."""
ticket_types = ctx.get("ticket_types") or []
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx)
+ _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx)
+ _ticket_types_header_html(ctx))
hdr += header_child_sx(child)
nav_html = sx_call("events-admin-placeholder-nav")
return full_page_sx(ctx, header_rows=hdr, content=content, menu=nav_html)
async def render_ticket_types_oob(ctx: dict) -> str:
"""OOB response: ticket types listing."""
ticket_types = ctx.get("ticket_types") or []
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
oobs = _entry_admin_header_html(ctx, oob=True)
oobs += oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
_ticket_types_header_html(ctx))
nav_html = sx_call("events-admin-placeholder-nav")
return oob_page_sx(oobs=oobs, content=content, menu=nav_html)
# =========================================================================== # ===========================================================================
# Ticket type page / OOB # Ticket type page / OOB
@@ -3317,41 +3011,6 @@ def _ticket_type_header_html(ctx: dict, *, oob: bool = False) -> str:
nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child-inner", oob=oob) nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child-inner", oob=oob)
async def render_ticket_type_page(ctx: dict) -> str:
"""Full page: single ticket type detail (extends ticket types)."""
ticket_type = ctx.get("ticket_type")
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx)
+ _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx)
+ _ticket_types_header_html(ctx) + _ticket_type_header_html(ctx))
hdr += header_child_sx(child)
nav_html = sx_call("events-admin-placeholder-nav")
return full_page_sx(ctx, header_rows=hdr, content=content, menu=nav_html)
async def render_ticket_type_oob(ctx: dict) -> str:
"""OOB response: single ticket type detail."""
ticket_type = ctx.get("ticket_type")
entry = ctx.get("entry")
calendar = ctx.get("calendar")
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
content = render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
oobs = _ticket_types_header_html(ctx, oob=True)
oobs += oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
_ticket_type_header_html(ctx))
nav_html = sx_call("events-admin-placeholder-nav")
return oob_page_sx(oobs=oobs, content=content, menu=nav_html)
# =========================================================================== # ===========================================================================
# Ticket type edit form # Ticket type edit form
# =========================================================================== # ===========================================================================

View File

@@ -0,0 +1,406 @@
"""Events defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_events_pages() -> None:
"""Register events-specific layouts, page helpers, and load page definitions."""
_register_events_layouts()
_register_events_helpers()
_load_events_page_files()
def _load_events_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "events")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_events_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("events-calendar-admin", _cal_admin_full, _cal_admin_oob)
register_custom_layout("events-slots", _slots_full, _slots_oob)
register_custom_layout("events-slot", _slot_full, _slot_oob)
register_custom_layout("events-day-admin", _day_admin_full, _day_admin_oob)
register_custom_layout("events-entry", _entry_full, _entry_oob)
register_custom_layout("events-entry-admin", _entry_admin_full, _entry_admin_oob)
register_custom_layout("events-ticket-types", _ticket_types_full, _ticket_types_oob)
register_custom_layout("events-ticket-type", _ticket_type_full, _ticket_type_oob)
register_custom_layout("events-markets", _markets_full, _markets_oob)
# --- Calendar admin layout (root + post + child(post-admin + calendar + cal-admin)) ---
async def _cal_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, post_admin_header_sx, header_child_sx
from sx.sx_components import (
_ensure_container_nav, _post_header_sx,
_calendar_header_sx, _calendar_admin_header_sx,
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = admin_hdr + _calendar_header_sx(ctx) + _calendar_admin_header_sx(ctx)
return root_hdr + post_hdr + header_child_sx(child)
async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import post_admin_header_sx, oob_header_sx
from sx.sx_components import (
_ensure_container_nav, _calendar_header_sx,
_calendar_admin_header_sx, _clear_deeper_oob,
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-header-child", "calendar-admin-header-child",
_calendar_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")
return oobs
# --- Slots layout (same full as cal-admin but different OOB) ---
async def _slots_full(ctx: dict, **kw: Any) -> str:
return await _cal_admin_full({**ctx, "is_admin_section": True}, **kw)
async def _slots_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import post_admin_header_sx
from sx.sx_components import (
_ensure_container_nav, _calendar_admin_header_sx, _clear_deeper_oob,
)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child")
return oobs
# --- Slot detail layout (extends cal-admin with slot header) ---
async def _slot_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, post_admin_header_sx, header_child_sx
from sx.sx_components import (
_ensure_container_nav, _post_header_sx,
_calendar_header_sx, _calendar_admin_header_sx, _slot_header_html,
)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx)
+ _calendar_admin_header_sx(ctx) + _slot_header_html(ctx))
return root_hdr + post_hdr + header_child_sx(child)
async def _slot_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import post_admin_header_sx, oob_header_sx
from sx.sx_components import (
_ensure_container_nav, _calendar_admin_header_sx,
_slot_header_html, _clear_deeper_oob,
)
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_admin_header_sx(ctx, oob=True))
oobs += oob_header_sx("calendar-admin-header-child", "slot-header-child",
_slot_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"calendar-admin-row", "calendar-admin-header-child",
"slot-row", "slot-header-child")
return oobs
# --- Day admin layout (root + post + post-admin + child(cal + day + day-admin)) ---
async def _day_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, post_admin_header_sx, header_child_sx
from sx.sx_components import (
_ensure_container_nav, _post_header_sx,
_calendar_header_sx, _day_header_sx, _day_admin_header_sx,
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _day_admin_header_sx(ctx))
return root_hdr + post_hdr + header_child_sx(child)
async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import post_admin_header_sx, oob_header_sx
from sx.sx_components import (
_ensure_container_nav, _calendar_header_sx,
_day_admin_header_sx, _clear_deeper_oob,
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _calendar_header_sx(ctx, oob=True))
oobs += oob_header_sx("day-header-child", "day-admin-header-child",
_day_admin_header_sx(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"day-admin-row", "day-admin-header-child")
return oobs
# --- Entry layout (root + child(post + cal + day + entry), + menu) ---
def _entry_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _calendar_header_sx,
_day_header_sx, _entry_header_html,
)
root_hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx))
return root_hdr + header_child_sx(child)
def _entry_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_day_header_sx, _entry_header_html, _clear_deeper_oob,
)
oobs = _day_header_sx(ctx, oob=True)
oobs += oob_header_sx("day-header-child", "entry-header-child",
_entry_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child")
return oobs
# --- Entry admin layout (root + post + child(post-admin + cal + day + entry + entry-admin), + menu) ---
async def _entry_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, post_admin_header_sx, header_child_sx
from sx.sx_components import (
_ensure_container_nav, _post_header_sx,
_calendar_header_sx, _day_header_sx,
_entry_header_html, _entry_admin_header_html,
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + _calendar_header_sx(ctx) + _day_header_sx(ctx)
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx))
return root_hdr + post_hdr + header_child_sx(child)
async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import post_admin_header_sx, oob_header_sx
from sx.sx_components import (
_ensure_container_nav, _entry_header_html,
_entry_admin_header_html, _clear_deeper_oob,
)
ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "")
oobs = (post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ _entry_header_html(ctx, oob=True))
oobs += oob_header_sx("entry-header-child", "entry-admin-header-child",
_entry_admin_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
"entry-row", "entry-header-child",
"entry-admin-row", "entry-admin-header-child")
return oobs
# --- Ticket types layout (extends entry admin with ticket-types header, + menu) ---
def _ticket_types_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _calendar_header_sx, _day_header_sx,
_entry_header_html, _entry_admin_header_html,
_ticket_types_header_html,
)
root_hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx)
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx))
return root_hdr + header_child_sx(child)
def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_entry_admin_header_html, _ticket_types_header_html, _clear_deeper_oob,
)
oobs = _entry_admin_header_html(ctx, oob=True)
oobs += oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
_ticket_types_header_html(ctx))
return oobs
# --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) ---
def _ticket_type_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _calendar_header_sx, _day_header_sx,
_entry_header_html, _entry_admin_header_html,
_ticket_types_header_html, _ticket_type_header_html,
)
root_hdr = root_header_sx(ctx)
child = (_post_header_sx(ctx) + _calendar_header_sx(ctx)
+ _day_header_sx(ctx) + _entry_header_html(ctx)
+ _entry_admin_header_html(ctx) + _ticket_types_header_html(ctx)
+ _ticket_type_header_html(ctx))
return root_hdr + header_child_sx(child)
def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_ticket_types_header_html, _ticket_type_header_html,
)
oobs = _ticket_types_header_html(ctx, oob=True)
oobs += oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
_ticket_type_header_html(ctx))
return oobs
# --- Markets layout (root + child(post + markets)) ---
def _markets_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _post_header_sx, _markets_header_sx
root_hdr = root_header_sx(ctx)
child = _post_header_sx(ctx) + _markets_header_sx(ctx)
return root_hdr + header_child_sx(child)
def _markets_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _post_header_sx, _markets_header_sx
oobs = _post_header_sx(ctx, oob=True)
oobs += oob_header_sx("post-header-child", "markets-header-child",
_markets_header_sx(ctx))
return oobs
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_events_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("events", {
"calendar-admin-content": _h_calendar_admin_content,
"day-admin-content": _h_day_admin_content,
"slots-content": _h_slots_content,
"slot-content": _h_slot_content,
"entry-content": _h_entry_content,
"entry-menu": _h_entry_menu,
"entry-admin-content": _h_entry_admin_content,
"admin-menu": _h_admin_menu,
"ticket-types-content": _h_ticket_types_content,
"ticket-type-content": _h_ticket_type_content,
"tickets-content": _h_tickets_content,
"ticket-detail-content": _h_ticket_detail_content,
"ticket-admin-content": _h_ticket_admin_content,
"markets-content": _h_markets_content,
})
def _h_calendar_admin_content():
from quart import g
return getattr(g, "calendar_admin_content", "")
def _h_day_admin_content():
from quart import g
return getattr(g, "day_admin_content", "")
def _h_slots_content():
from quart import g
return getattr(g, "slots_content", "")
def _h_slot_content():
from quart import g
return getattr(g, "slot_content", "")
def _h_entry_content():
from quart import g
return getattr(g, "entry_content", "")
def _h_entry_menu():
from quart import g
return getattr(g, "entry_menu", "")
def _h_entry_admin_content():
from quart import g
return getattr(g, "entry_admin_content", "")
def _h_admin_menu():
from shared.sx.helpers import sx_call
return sx_call("events-admin-placeholder-nav")
def _h_ticket_types_content():
from quart import g
return getattr(g, "ticket_types_content", "")
def _h_ticket_type_content():
from quart import g
return getattr(g, "ticket_type_content", "")
def _h_tickets_content():
from quart import g
return getattr(g, "tickets_content", "")
def _h_ticket_detail_content():
from quart import g
return getattr(g, "ticket_detail_content", "")
def _h_ticket_admin_content():
from quart import g
return getattr(g, "ticket_admin_content", "")
def _h_markets_content():
from quart import g
return getattr(g, "markets_content", "")

View File

@@ -0,0 +1,89 @@
;; Events pages — mounted on various nested blueprints
;; Calendar admin (mounted on calendar.admin bp)
(defpage calendar-admin
:path "/"
:auth :admin
:layout :events-calendar-admin
:content (calendar-admin-content))
;; Day admin (mounted on day.admin bp)
(defpage day-admin
:path "/"
:auth :admin
:layout :events-day-admin
:content (day-admin-content))
;; Slots listing (mounted on slots bp)
(defpage slots-listing
:path "/"
:auth :public
:layout :events-slots
:content (slots-content))
;; Slot detail (mounted on slot bp)
(defpage slot-detail
:path "/"
:auth :admin
:layout :events-slot
:content (slot-content))
;; Entry detail (mounted on calendar_entry bp)
(defpage entry-detail
:path "/"
:auth :admin
:layout :events-entry
:content (entry-content)
:menu (entry-menu))
;; Entry admin (mounted on calendar_entry.admin bp)
(defpage entry-admin
:path "/"
:auth :admin
:layout :events-entry-admin
:content (entry-admin-content)
:menu (admin-menu))
;; Ticket types listing (mounted on ticket_types bp)
(defpage ticket-types-listing
:path "/"
:auth :public
:layout :events-ticket-types
:content (ticket-types-content)
:menu (admin-menu))
;; Ticket type detail (mounted on ticket_type bp)
(defpage ticket-type-detail
:path "/"
:auth :admin
:layout :events-ticket-type
:content (ticket-type-content)
:menu (admin-menu))
;; My tickets (mounted on tickets bp)
(defpage my-tickets
:path "/"
:auth :public
:layout :root
:content (tickets-content))
;; Ticket detail (mounted on tickets bp)
(defpage ticket-detail
:path "/<code>/"
:auth :public
:layout :root
:content (ticket-detail-content))
;; Ticket admin dashboard (mounted on ticket_admin bp)
(defpage ticket-admin
:path "/"
:auth :admin
:layout :root
:content (ticket-admin-content))
;; Markets (mounted on markets bp)
(defpage events-markets
:path "/"
:auth :public
:layout :events-markets
:content (markets-content))

View File

@@ -1,7 +1,7 @@
<!-- Desktop nav --> <!-- Desktop nav -->
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% call links.link( {% call links.link(
url_for('calendar.slots.get', calendar_slug=calendar.slug), url_for('calendar.slots.defpage_slots_listing', calendar_slug=calendar.slug),
hx_select_search, hx_select_search,
select_colours, select_colours,
True, True,
@@ -14,5 +14,5 @@
{% endcall %} {% endcall %}
{% if g.rights.admin %} {% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %} {% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('calendar.admin.admin', calendar_slug=calendar.slug)) }} {{ admin_nav_item(url_for('calendar.admin.defpage_calendar_admin', calendar_slug=calendar.slug)) }}
{% endif %} {% endif %}

View File

@@ -23,7 +23,7 @@
<div class="text-xs font-medium"> <div class="text-xs font-medium">
{% call links.link( {% call links.link(
url_for( url_for(
'calendar.slots.slot.get', 'calendar.slots.slot.defpage_slot_detail',
calendar_slug=calendar.slug, calendar_slug=calendar.slug,
slot_id=entry.slot.id slot_id=entry.slot.id
), ),

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='markets-row', oob=oob) %} {% call links.menu_row(id='markets-row', oob=oob) %}
{% call links.link(url_for('markets.home'), hx_select_search) %} {% call links.link(url_for('markets.defpage_events_markets'), hx_select_search) %}
<i class="fa fa-shopping-bag" aria-hidden="true"></i> <i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div> <div>
Markets Markets

View File

@@ -77,7 +77,7 @@
<a <a
class="relative inline-flex items-center justify-center text-emerald-700" class="relative inline-flex items-center justify-center text-emerald-700"
href="{{ url_for('tickets.my_tickets') }}" href="{{ url_for('tickets.defpage_my_tickets') }}"
> >
<span class="relative inline-flex items-center justify-center"> <span class="relative inline-flex items-center justify-center">
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i> <i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
@@ -166,7 +166,7 @@
<a <a
class="relative inline-flex items-center justify-center text-emerald-700" class="relative inline-flex items-center justify-center text-emerald-700"
href="{{ url_for('tickets.my_tickets') }}" href="{{ url_for('tickets.defpage_my_tickets') }}"
> >
<span class="relative inline-flex items-center justify-center"> <span class="relative inline-flex items-center justify-center">
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i> <i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>

View File

@@ -14,7 +14,7 @@
<div class="space-y-2 mb-4"> <div class="space-y-2 mb-4">
{% for ticket in created_tickets %} {% for ticket in created_tickets %}
<a <a
href="{{ url_for('tickets.ticket_detail', code=ticket.code) }}" href="{{ url_for('tickets.defpage_ticket_detail', code=ticket.code) }}"
class="flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm" class="flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -34,7 +34,7 @@
<div class="mt-3 flex gap-2"> <div class="mt-3 flex gap-2">
<a <a
href="{{ url_for('tickets.my_tickets') }}" href="{{ url_for('tickets.defpage_my_tickets') }}"
class="text-sm text-emerald-700 hover:text-emerald-900 underline" class="text-sm text-emerald-700 hover:text-emerald-900 underline"
> >
View all my tickets View all my tickets

View File

@@ -1,7 +1,7 @@
<section id="ticket-detail" class="{{styles.list_container}} max-w-lg mx-auto"> <section id="ticket-detail" class="{{styles.list_container}} max-w-lg mx-auto">
{# Back link #} {# Back link #}
<a href="{{ url_for('tickets.my_tickets') }}" <a href="{{ url_for('tickets.defpage_my_tickets') }}"
class="inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4"> class="inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4">
<i class="fa fa-arrow-left" aria-hidden="true"></i> <i class="fa fa-arrow-left" aria-hidden="true"></i>
Back to my tickets Back to my tickets

View File

@@ -5,7 +5,7 @@
<div class="space-y-4"> <div class="space-y-4">
{% for ticket in tickets %} {% for ticket in tickets %}
<a <a
href="{{ url_for('tickets.ticket_detail', code=ticket.code) }}" href="{{ url_for('tickets.defpage_ticket_detail', code=ticket.code) }}"
class="block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition" class="block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition"
> >
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">

View File

@@ -84,11 +84,20 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# --- defpage setup ---
from sxc.pages import setup_federation_pages
setup_federation_pages()
# --- blueprints --- # --- blueprints ---
# Well-known + actors (webfinger, inbox, outbox, etc.) are now handled # Well-known + actors (webfinger, inbox, outbox, etc.) are now handled
# by the shared AP blueprint registered in create_base_app(). # by the shared AP blueprint registered in create_base_app().
app.register_blueprint(register_identity_bp()) app.register_blueprint(register_identity_bp())
app.register_blueprint(register_social_bp())
social_bp = register_social_bp()
from shared.sx.pages import mount_pages
mount_pages(social_bp, "federation")
app.register_blueprint(social_bp)
app.register_blueprint(register_fragments()) app.register_blueprint(register_fragments())
# --- home page --- # --- home page ---

View File

@@ -32,18 +32,103 @@ def register(url_prefix="/social"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id) actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor g._social_actor = actor
# -- Timeline ------------------------------------------------------------- @bp.before_request
async def _prepare_page_data():
"""Pre-render content for defpage routes."""
endpoint = request.endpoint or ""
@bp.get("/") if endpoint.endswith("defpage_home_timeline"):
async def home_timeline(): actor = _require_actor()
if not g.get("user"): items = await services.federation.get_home_timeline(g.s, actor.id)
return redirect(url_for("auth.login_form")) from sx.sx_components import _timeline_content_sx
actor = _require_actor() g.home_timeline_content = _timeline_content_sx(items, "home", actor)
items = await services.federation.get_home_timeline(g.s, actor.id)
from shared.sx.page import get_template_context elif endpoint.endswith("defpage_public_timeline"):
from sx.sx_components import render_timeline_page actor = getattr(g, "_social_actor", None)
ctx = await get_template_context() items = await services.federation.get_public_timeline(g.s)
return await render_timeline_page(ctx, items, "home", actor) from sx.sx_components import _timeline_content_sx
g.public_timeline_content = _timeline_content_sx(items, "public", actor)
elif endpoint.endswith("defpage_compose_form"):
actor = _require_actor()
from sx.sx_components import _compose_content_sx
reply_to = request.args.get("reply_to")
g.compose_content = _compose_content_sx(actor, reply_to)
elif endpoint.endswith("defpage_search"):
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _search_content_sx
g.search_content = _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
elif endpoint.endswith("defpage_following_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from sx.sx_components import _following_content_sx
g.following_content = _following_content_sx(actors_list, total, actor)
elif endpoint.endswith("defpage_followers_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _followers_content_sx
g.followers_content = _followers_content_sx(actors_list, total, followed_urls, actor)
elif endpoint.endswith("defpage_actor_timeline"):
actor = getattr(g, "_social_actor", None)
actor_id = request.view_args.get("id")
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from sx.sx_components import _actor_timeline_content_sx
g.actor_timeline_content = _actor_timeline_content_sx(remote_dto, items, is_following, actor)
elif endpoint.endswith("defpage_notifications"):
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from sx.sx_components import _notifications_content_sx
g.notifications_content = _notifications_content_sx(items)
# -- Timeline pagination ---------------------------------------------------
@bp.get("/timeline") @bp.get("/timeline")
async def home_timeline_page(): async def home_timeline_page():
@@ -62,15 +147,6 @@ def register(url_prefix="/social"):
sx_src = await render_timeline_items(items, "home", actor) sx_src = await render_timeline_items(items, "home", actor)
return sx_response(sx_src) return sx_response(sx_src)
@bp.get("/public")
async def public_timeline():
items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None)
from shared.sx.page import get_template_context
from sx.sx_components import render_timeline_page
ctx = await get_template_context()
return await render_timeline_page(ctx, items, "public", actor)
@bp.get("/public/timeline") @bp.get("/public/timeline")
async def public_timeline_page(): async def public_timeline_page():
before_str = request.args.get("before") before_str = request.args.get("before")
@@ -86,16 +162,7 @@ def register(url_prefix="/social"):
sx_src = await render_timeline_items(items, "public", actor) sx_src = await render_timeline_items(items, "public", actor)
return sx_response(sx_src) return sx_response(sx_src)
# -- Compose -------------------------------------------------------------- # -- Compose ---------------------------------------------------------------
@bp.get("/compose")
async def compose_form():
actor = _require_actor()
reply_to = request.args.get("reply_to")
from shared.sx.page import get_template_context
from sx.sx_components import render_compose_page
ctx = await get_template_context()
return await render_compose_page(ctx, actor, reply_to)
@bp.post("/compose") @bp.post("/compose")
async def compose_submit(): async def compose_submit():
@@ -103,7 +170,7 @@ def register(url_prefix="/social"):
form = await request.form form = await request.form
content = form.get("content", "").strip() content = form.get("content", "").strip()
if not content: if not content:
return redirect(url_for("social.compose_form")) return redirect(url_for("social.defpage_compose_form"))
visibility = form.get("visibility", "public") visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None in_reply_to = form.get("in_reply_to") or None
@@ -114,45 +181,26 @@ def register(url_prefix="/social"):
visibility=visibility, visibility=visibility,
in_reply_to=in_reply_to, in_reply_to=in_reply_to,
) )
return redirect(url_for("social.home_timeline")) return redirect(url_for("social.defpage_home_timeline"))
@bp.post("/delete/<int:post_id>") @bp.post("/delete/<int:post_id>")
async def delete_post(post_id: int): async def delete_post(post_id: int):
actor = _require_actor() actor = _require_actor()
await services.federation.delete_local_post(g.s, actor.id, post_id) await services.federation.delete_local_post(g.s, actor.id, post_id)
return redirect(url_for("social.home_timeline")) return redirect(url_for("social.defpage_home_timeline"))
# -- Search + Follow ------------------------------------------------------ # -- Search + Follow -------------------------------------------------------
@bp.get("/search")
async def search():
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
actors = []
total = 0
followed_urls: set[str] = set()
if query:
actors, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from shared.sx.page import get_template_context
from sx.sx_components import render_search_page
ctx = await get_template_context()
return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor)
@bp.get("/search/page") @bp.get("/search/page")
async def search_page(): async def search_page():
actor = getattr(g, "_social_actor", None) actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip() query = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
actors = [] actors_list = []
total = 0 total = 0
followed_urls: set[str] = set() followed_urls: set[str] = set()
if query: if query:
actors, total = await services.federation.search_actors( actors_list, total = await services.federation.search_actors(
g.s, query, page=page, g.s, query, page=page,
) )
if actor: if actor:
@@ -161,7 +209,7 @@ def register(url_prefix="/social"):
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
from sx.sx_components import render_search_results from sx.sx_components import render_search_results
sx_src = await render_search_results(actors, query, page, followed_urls, actor) sx_src = await render_search_results(actors_list, query, page, followed_urls, actor)
return sx_response(sx_src) return sx_response(sx_src)
@bp.post("/follow") @bp.post("/follow")
@@ -175,7 +223,7 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=True) return await _actor_card_response(actor, remote_actor_url, is_followed=True)
return redirect(request.referrer or url_for("social.search")) return redirect(request.referrer or url_for("social.defpage_search"))
@bp.post("/unfollow") @bp.post("/unfollow")
async def unfollow(): async def unfollow():
@@ -188,7 +236,7 @@ def register(url_prefix="/social"):
) )
if request.headers.get("SX-Request") or request.headers.get("HX-Request"): if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
return await _actor_card_response(actor, remote_actor_url, is_followed=False) return await _actor_card_response(actor, remote_actor_url, is_followed=False)
return redirect(request.referrer or url_for("social.search")) return redirect(request.referrer or url_for("social.defpage_search"))
async def _actor_card_response(actor, remote_actor_url, is_followed): async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX.""" """Re-render a single actor card after follow/unfollow via HTMX."""
@@ -198,7 +246,6 @@ def register(url_prefix="/social"):
if not remote_dto: if not remote_dto:
return Response("", status=200) return Response("", status=200)
followed_urls = {remote_actor_url} if is_followed else set() followed_urls = {remote_actor_url} if is_followed else set()
# Detect list context from referer
referer = request.referrer or "" referer = request.referrer or ""
if "/followers" in referer: if "/followers" in referer:
list_type = "followers" list_type = "followers"
@@ -207,7 +254,7 @@ def register(url_prefix="/social"):
from sx.sx_components import render_actor_card from sx.sx_components import render_actor_card
return sx_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type)) return sx_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
# -- Interactions --------------------------------------------------------- # -- Interactions ----------------------------------------------------------
@bp.post("/like") @bp.post("/like")
async def like(): async def like():
@@ -216,7 +263,6 @@ def register(url_prefix="/social"):
object_id = form.get("object_id", "") object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "") author_inbox = form.get("author_inbox", "")
await services.federation.like_post(g.s, actor.id, object_id, author_inbox) await services.federation.like_post(g.s, actor.id, object_id, author_inbox)
# Return updated buttons for HTMX
return await _interaction_buttons_response(actor, object_id, author_inbox) return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/unlike") @bp.post("/unlike")
@@ -250,7 +296,6 @@ def register(url_prefix="/social"):
"""Re-render interaction buttons after a like/boost action.""" """Re-render interaction buttons after a like/boost action."""
from shared.models.federation import APInteraction, APRemotePost, APActivity from shared.models.federation import APInteraction, APRemotePost, APActivity
from sqlalchemy import select from sqlalchemy import select
from shared.services.federation_impl import SqlFederationService
svc = services.federation svc = services.federation
post_type, post_id = await svc._resolve_post(g.s, object_id) post_type, post_id = await svc._resolve_post(g.s, object_id)
@@ -304,51 +349,24 @@ def register(url_prefix="/social"):
actor=actor, actor=actor,
)) ))
# -- Following / Followers ------------------------------------------------ # -- Following / Followers pagination --------------------------------------
@bp.get("/following")
async def following_list():
actor = _require_actor()
actors, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from shared.sx.page import get_template_context
from sx.sx_components import render_following_page
ctx = await get_template_context()
return await render_following_page(ctx, actors, total, actor)
@bp.get("/following/page") @bp.get("/following/page")
async def following_list_page(): async def following_list_page():
actor = _require_actor() actor = _require_actor()
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
actors, total = await services.federation.get_following( actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page, g.s, actor.preferred_username, page=page,
) )
from sx.sx_components import render_following_items from sx.sx_components import render_following_items
sx_src = await render_following_items(actors, page, actor) sx_src = await render_following_items(actors_list, page, actor)
return sx_response(sx_src) return sx_response(sx_src)
@bp.get("/followers")
async def followers_list():
actor = _require_actor()
actors, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
# Build set of followed actor URLs to show Follow Back vs Unfollow
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from shared.sx.page import get_template_context
from sx.sx_components import render_followers_page
ctx = await get_template_context()
return await render_followers_page(ctx, actors, total, followed_urls, actor)
@bp.get("/followers/page") @bp.get("/followers/page")
async def followers_list_page(): async def followers_list_page():
actor = _require_actor() actor = _require_actor()
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
actors, total = await services.federation.get_followers_paginated( actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username, page=page, g.s, actor.preferred_username, page=page,
) )
following, _ = await services.federation.get_following( following, _ = await services.federation.get_following(
@@ -356,43 +374,9 @@ def register(url_prefix="/social"):
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
from sx.sx_components import render_followers_items from sx.sx_components import render_followers_items
sx_src = await render_followers_items(actors, page, followed_urls, actor) sx_src = await render_followers_items(actors_list, page, followed_urls, actor)
return sx_response(sx_src) return sx_response(sx_src)
@bp.get("/actor/<int:id>")
async def actor_timeline(id: int):
actor = getattr(g, "_social_actor", None)
# Get remote actor info
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, id)
# Check if we follow this actor
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from shared.sx.page import get_template_context
from sx.sx_components import render_actor_timeline_page
ctx = await get_template_context()
return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor)
@bp.get("/actor/<int:id>/timeline") @bp.get("/actor/<int:id>/timeline")
async def actor_timeline_page(id: int): async def actor_timeline_page(id: int):
actor = getattr(g, "_social_actor", None) actor = getattr(g, "_social_actor", None)
@@ -410,17 +394,7 @@ def register(url_prefix="/social"):
sx_src = await render_actor_timeline_items(items, id, actor) sx_src = await render_actor_timeline_items(items, id, actor)
return sx_response(sx_src) return sx_response(sx_src)
# -- Notifications -------------------------------------------------------- # -- Notifications ---------------------------------------------------------
@bp.get("/notifications")
async def notifications():
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from shared.sx.page import get_template_context
from sx.sx_components import render_notifications_page
ctx = await get_template_context()
return await render_notifications_page(ctx, items, actor)
@bp.get("/notifications/count") @bp.get("/notifications/count")
async def notification_count(): async def notification_count():
@@ -440,6 +414,6 @@ def register(url_prefix="/social"):
async def mark_read(): async def mark_read():
actor = _require_actor() actor = _require_actor()
await services.federation.mark_notifications_read(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id)
return redirect(url_for("social.notifications")) return redirect(url_for("social.defpage_notifications"))
return bp return bp

View File

@@ -35,12 +35,12 @@ def _social_nav_sx(actor: Any) -> str:
return sx_call("federation-nav-choose-username", url=choose_url) return sx_call("federation-nav-choose-username", url=choose_url)
links = [ links = [
("social.home_timeline", "Timeline"), ("social.defpage_home_timeline", "Timeline"),
("social.public_timeline", "Public"), ("social.defpage_public_timeline", "Public"),
("social.compose_form", "Compose"), ("social.defpage_compose_form", "Compose"),
("social.following_list", "Following"), ("social.defpage_following_list", "Following"),
("social.followers_list", "Followers"), ("social.defpage_followers_list", "Followers"),
("social.search", "Search"), ("social.defpage_search", "Search"),
] ]
parts = [] parts = []
@@ -51,7 +51,7 @@ def _social_nav_sx(actor: Any) -> str:
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})') parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
# Notifications with live badge # Notifications with live badge
notif_url = url_for("social.notifications") notif_url = url_for("social.defpage_notifications")
notif_count_url = url_for("social.notification_count") notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else "" notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(sx_call( parts.append(sx_call(
@@ -122,7 +122,7 @@ def _interaction_buttons_sx(item: Any, actor: Any) -> str:
boost_action = url_for("social.boost") boost_action = url_for("social.boost")
boost_cls = "hover:text-green-600" boost_cls = "hover:text-green-600"
reply_url = url_for("social.compose_form", reply_to=oid) if oid else "" reply_url = url_for("social.defpage_compose_form", reply_to=oid) if oid else ""
reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else "" reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else ""
like_form = sx_call( like_form = sx_call(
@@ -260,7 +260,7 @@ def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
if (list_type in ("following", "search")) and aid: if (list_type in ("following", "search")) and aid:
name_sx = sx_call( name_sx = sx_call(
"federation-actor-name-link", "federation-actor-name-link",
href=url_for("social.actor_timeline", id=aid), href=url_for("social.defpage_actor_timeline", id=aid),
name=str(escape(display_name)), name=str(escape(display_name)),
) )
else: else:
@@ -436,32 +436,28 @@ async def render_check_email_page(ctx: dict) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API: Timeline # Content builders (used by defpage before_request)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def render_timeline_page(ctx: dict, items: list, timeline_type: str, def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str:
actor: Any) -> str: """Build timeline content SX string."""
"""Full page: timeline (home or public)."""
from quart import url_for from quart import url_for
label = "Home" if timeline_type == "home" else "Public" label = "Home" if timeline_type == "home" else "Public"
compose_sx = "" compose_sx = ""
if actor: if actor:
compose_url = url_for("social.compose_form") compose_url = url_for("social.defpage_compose_form")
compose_sx = sx_call("federation-compose-button", url=compose_url) compose_sx = sx_call("federation-compose-button", url=compose_url)
timeline_sx = _timeline_items_sx(items, timeline_type, actor) timeline_sx = _timeline_items_sx(items, timeline_type, actor)
content = sx_call( return sx_call(
"federation-timeline-page", "federation-timeline-page",
label=label, label=label,
compose=SxExpr(compose_sx) if compose_sx else None, compose=SxExpr(compose_sx) if compose_sx else None,
timeline=SxExpr(timeline_sx) if timeline_sx else None, timeline=SxExpr(timeline_sx) if timeline_sx else None,
) )
return _social_page(ctx, actor, content=content,
title=f"{label} Timeline \u2014 Rose Ash")
async def render_timeline_items(items: list, timeline_type: str, async def render_timeline_items(items: list, timeline_type: str,
actor: Any, actor_id: int | None = None) -> str: actor: Any, actor_id: int | None = None) -> str:
@@ -469,12 +465,8 @@ async def render_timeline_items(items: list, timeline_type: str,
return _timeline_items_sx(items, timeline_type, actor, actor_id) return _timeline_items_sx(items, timeline_type, actor, actor_id)
# --------------------------------------------------------------------------- def _compose_content_sx(actor: Any, reply_to: str | None) -> str:
# Public API: Compose """Build compose form content SX string."""
# ---------------------------------------------------------------------------
async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> str:
"""Full page: compose form."""
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
from quart import url_for from quart import url_for
@@ -488,26 +480,19 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st
reply_to=str(escape(reply_to)), reply_to=str(escape(reply_to)),
) )
content = sx_call( return sx_call(
"federation-compose-form", "federation-compose-form",
action=action, csrf=csrf, action=action, csrf=csrf,
reply=SxExpr(reply_sx) if reply_sx else None, reply=SxExpr(reply_sx) if reply_sx else None,
) )
return _social_page(ctx, actor, content=content,
title="Compose \u2014 Rose Ash")
def _search_content_sx(query: str, actors: list, total: int,
# --------------------------------------------------------------------------- page: int, followed_urls: set, actor: Any) -> str:
# Public API: Search """Build search page content SX string."""
# ---------------------------------------------------------------------------
async def render_search_page(ctx: dict, query: str, actors: list, total: int,
page: int, followed_urls: set, actor: Any) -> str:
"""Full page: search."""
from quart import url_for from quart import url_for
search_url = url_for("social.search") search_url = url_for("social.defpage_search")
search_page_url = url_for("social.search_page") search_page_url = url_for("social.search_page")
results_sx = _search_results_sx(actors, query, page, followed_urls, actor) results_sx = _search_results_sx(actors, query, page, followed_urls, actor)
@@ -527,7 +512,7 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
text=f"No results found for <strong>{escape(query)}</strong>", text=f"No results found for <strong>{escape(query)}</strong>",
) )
content = sx_call( return sx_call(
"federation-search-page", "federation-search-page",
search_url=search_url, search_page_url=search_page_url, search_url=search_url, search_page_url=search_page_url,
query=str(escape(query)), query=str(escape(query)),
@@ -535,9 +520,6 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
results=SxExpr(results_sx) if results_sx else None, results=SxExpr(results_sx) if results_sx else None,
) )
return _social_page(ctx, actor, content=content,
title="Search \u2014 Rose Ash")
async def render_search_results(actors: list, query: str, page: int, async def render_search_results(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str: followed_urls: set, actor: Any) -> str:
@@ -545,21 +527,14 @@ async def render_search_results(actors: list, query: str, page: int,
return _search_results_sx(actors, query, page, followed_urls, actor) return _search_results_sx(actors, query, page, followed_urls, actor)
# --------------------------------------------------------------------------- def _following_content_sx(actors: list, total: int, actor: Any) -> str:
# Public API: Following / Followers """Build following list content SX string."""
# ---------------------------------------------------------------------------
async def render_following_page(ctx: dict, actors: list, total: int,
actor: Any) -> str:
"""Full page: following list."""
items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor) items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor)
content = sx_call( return sx_call(
"federation-actor-list-page", "federation-actor-list-page",
title="Following", count_str=f"({total})", title="Following", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None, items=SxExpr(items_sx) if items_sx else None,
) )
return _social_page(ctx, actor, content=content,
title="Following \u2014 Rose Ash")
async def render_following_items(actors: list, page: int, actor: Any) -> str: async def render_following_items(actors: list, page: int, actor: Any) -> str:
@@ -567,17 +542,15 @@ async def render_following_items(actors: list, page: int, actor: Any) -> str:
return _actor_list_items_sx(actors, page, "following", set(), actor) return _actor_list_items_sx(actors, page, "following", set(), actor)
async def render_followers_page(ctx: dict, actors: list, total: int, def _followers_content_sx(actors: list, total: int,
followed_urls: set, actor: Any) -> str: followed_urls: set, actor: Any) -> str:
"""Full page: followers list.""" """Build followers list content SX string."""
items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor) items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor)
content = sx_call( return sx_call(
"federation-actor-list-page", "federation-actor-list-page",
title="Followers", count_str=f"({total})", title="Followers", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None, items=SxExpr(items_sx) if items_sx else None,
) )
return _social_page(ctx, actor, content=content,
title="Followers \u2014 Rose Ash")
async def render_followers_items(actors: list, page: int, async def render_followers_items(actors: list, page: int,
@@ -586,13 +559,9 @@ async def render_followers_items(actors: list, page: int,
return _actor_list_items_sx(actors, page, "followers", followed_urls, actor) return _actor_list_items_sx(actors, page, "followers", followed_urls, actor)
# --------------------------------------------------------------------------- def _actor_timeline_content_sx(remote_actor: Any, items: list,
# Public API: Actor timeline is_following: bool, actor: Any) -> str:
# --------------------------------------------------------------------------- """Build actor timeline content SX string."""
async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
is_following: bool, actor: Any) -> str:
"""Full page: remote actor timeline."""
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
from quart import url_for from quart import url_for
@@ -640,15 +609,12 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
follow=SxExpr(follow_sx) if follow_sx else None, follow=SxExpr(follow_sx) if follow_sx else None,
) )
content = sx_call( return sx_call(
"federation-actor-timeline-layout", "federation-actor-timeline-layout",
header=SxExpr(header_sx), header=SxExpr(header_sx),
timeline=SxExpr(timeline_sx) if timeline_sx else None, timeline=SxExpr(timeline_sx) if timeline_sx else None,
) )
return _social_page(ctx, actor, content=content,
title=f"{display_name} \u2014 Rose Ash")
async def render_actor_timeline_items(items: list, actor_id: int, async def render_actor_timeline_items(items: list, actor_id: int,
actor: Any) -> str: actor: Any) -> str:
@@ -656,13 +622,8 @@ async def render_actor_timeline_items(items: list, actor_id: int,
return _timeline_items_sx(items, "actor", actor, actor_id) return _timeline_items_sx(items, "actor", actor, actor_id)
# --------------------------------------------------------------------------- def _notifications_content_sx(notifications: list) -> str:
# Public API: Notifications """Build notifications content SX string."""
# ---------------------------------------------------------------------------
async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str:
"""Full page: notifications."""
if not notifications: if not notifications:
notif_sx = sx_call("empty-state", message="No notifications yet.", notif_sx = sx_call("empty-state", message="No notifications yet.",
cls="text-stone-500") cls="text-stone-500")
@@ -673,9 +634,7 @@ async def render_notifications_page(ctx: dict, notifications: list,
items=SxExpr(items_sx), items=SxExpr(items_sx),
) )
content = sx_call("federation-notifications-page", notifs=SxExpr(notif_sx)) return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
return _social_page(ctx, actor, content=content,
title="Notifications \u2014 Rose Ash")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -0,0 +1,109 @@
"""Federation defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_federation_pages() -> None:
"""Register federation-specific layouts, page helpers, and load page definitions."""
_register_federation_layouts()
_register_federation_helpers()
_load_federation_page_files()
def _load_federation_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "federation")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_federation_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("social", _social_full, _social_oob)
def _social_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _social_header_sx
actor = ctx.get("actor")
root_hdr = root_header_sx(ctx)
social_hdr = _social_header_sx(actor)
child = header_child_sx(social_hdr)
return "(<> " + root_hdr + " " + child + ")"
def _social_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _social_header_sx
actor = ctx.get("actor")
social_hdr = _social_header_sx(actor)
child_oob = sx_call("oob-header-sx",
parent_id="root-header-child",
row=SxExpr(social_hdr))
root_hdr_oob = root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + root_hdr_oob + ")"
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_federation_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("federation", {
"home-timeline-content": _h_home_timeline_content,
"public-timeline-content": _h_public_timeline_content,
"compose-content": _h_compose_content,
"search-content": _h_search_content,
"following-content": _h_following_content,
"followers-content": _h_followers_content,
"actor-timeline-content": _h_actor_timeline_content,
"notifications-content": _h_notifications_content,
})
def _h_home_timeline_content():
from quart import g
return getattr(g, "home_timeline_content", "")
def _h_public_timeline_content():
from quart import g
return getattr(g, "public_timeline_content", "")
def _h_compose_content():
from quart import g
return getattr(g, "compose_content", "")
def _h_search_content():
from quart import g
return getattr(g, "search_content", "")
def _h_following_content():
from quart import g
return getattr(g, "following_content", "")
def _h_followers_content():
from quart import g
return getattr(g, "followers_content", "")
def _h_actor_timeline_content():
from quart import g
return getattr(g, "actor_timeline_content", "")
def _h_notifications_content():
from quart import g
return getattr(g, "notifications_content", "")

View File

@@ -0,0 +1,49 @@
;; Federation social pages
(defpage home-timeline
:path "/"
:auth :login
:layout :social
:content (home-timeline-content))
(defpage public-timeline
:path "/public"
:auth :public
:layout :social
:content (public-timeline-content))
(defpage compose-form
:path "/compose"
:auth :login
:layout :social
:content (compose-content))
(defpage search
:path "/search"
:auth :public
:layout :social
:content (search-content))
(defpage following-list
:path "/following"
:auth :login
:layout :social
:content (following-content))
(defpage followers-list
:path "/followers"
:auth :login
:layout :social
:content (followers-content))
(defpage actor-timeline
:path "/actor/<int:id>"
:auth :public
:layout :social
:content (actor-timeline-content))
(defpage notifications
:path "/notifications"
:auth :login
:layout :social
:content (notifications-content))

View File

@@ -4,32 +4,32 @@
<div class="w-full flex flex-row items-center gap-2 flex-wrap"> <div class="w-full flex flex-row items-center gap-2 flex-wrap">
{% if actor %} {% if actor %}
<nav class="flex gap-3 text-sm items-center flex-wrap"> <nav class="flex gap-3 text-sm items-center flex-wrap">
<a href="{{ url_for('social.home_timeline') }}" <a href="{{ url_for('social.defpage_home_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.home_timeline') %}font-bold{% endif %}"> class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_home_timeline') %}font-bold{% endif %}">
Timeline Timeline
</a> </a>
<a href="{{ url_for('social.public_timeline') }}" <a href="{{ url_for('social.defpage_public_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.public_timeline') %}font-bold{% endif %}"> class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_public_timeline') %}font-bold{% endif %}">
Public Public
</a> </a>
<a href="{{ url_for('social.compose_form') }}" <a href="{{ url_for('social.defpage_compose_form') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.compose_form') %}font-bold{% endif %}"> class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_compose_form') %}font-bold{% endif %}">
Compose Compose
</a> </a>
<a href="{{ url_for('social.following_list') }}" <a href="{{ url_for('social.defpage_following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.following_list') %}font-bold{% endif %}"> class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_following_list') %}font-bold{% endif %}">
Following Following
</a> </a>
<a href="{{ url_for('social.followers_list') }}" <a href="{{ url_for('social.defpage_followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.followers_list') %}font-bold{% endif %}"> class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_followers_list') %}font-bold{% endif %}">
Followers Followers
</a> </a>
<a href="{{ url_for('social.search') }}" <a href="{{ url_for('social.defpage_search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.search') %}font-bold{% endif %}"> class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_search') %}font-bold{% endif %}">
Search Search
</a> </a>
<a href="{{ url_for('social.notifications') }}" <a href="{{ url_for('social.defpage_notifications') }}"
class="px-2 py-1 rounded hover:bg-stone-200 relative {% if request.path == url_for('social.notifications') %}font-bold{% endif %}"> class="px-2 py-1 rounded hover:bg-stone-200 relative {% if request.path == url_for('social.defpage_notifications') %}font-bold{% endif %}">
Notifications Notifications
<span sx-get="{{ url_for('social.notification_count') }}" sx-trigger="load, every 30s" sx-swap="innerHTML" <span sx-get="{{ url_for('social.notification_count') }}" sx-trigger="load, every 30s" sx-swap="innerHTML"
class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span> class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span>

View File

@@ -11,7 +11,7 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
{% if list_type == "following" and a.id %} {% if list_type == "following" and a.id %}
<a href="{{ url_for('social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline"> <a href="{{ url_for('social.defpage_actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }} {{ a.display_name or a.preferred_username }}
</a> </a>
{% else %} {% else %}

View File

@@ -55,7 +55,7 @@
{% endif %} {% endif %}
{% if oid %} {% if oid %}
<a href="{{ url_for('social.compose_form', reply_to=oid) }}" <a href="{{ url_for('social.defpage_compose_form', reply_to=oid) }}"
class="hover:text-stone-700">Reply</a> class="hover:text-stone-700">Reply</a>
{% endif %} {% endif %}
</div> </div>

View File

@@ -11,7 +11,7 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
{% if a.id %} {% if a.id %}
<a href="{{ url_for('social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline"> <a href="{{ url_for('social.defpage_actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }} {{ a.display_name or a.preferred_username }}
</a> </a>
{% else %} {% else %}

View File

@@ -5,10 +5,10 @@
{% block social_content %} {% block social_content %}
<h1 class="text-2xl font-bold mb-6">Search</h1> <h1 class="text-2xl font-bold mb-6">Search</h1>
<form method="get" action="{{ url_for('social.search') }}" class="mb-6" <form method="get" action="{{ url_for('social.defpage_search') }}" class="mb-6"
sx-get="{{ url_for('social.search_page') }}" sx-get="{{ url_for('social.defpage_search_page') }}"
sx-target="#search-results" sx-target="#search-results"
sx-push-url="{{ url_for('social.search') }}"> sx-push-url="{{ url_for('social.defpage_search') }}">
<div class="flex gap-2"> <div class="flex gap-2">
<input type="text" name="q" value="{{ query }}" <input type="text" name="q" value="{{ query }}"
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500" class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"

View File

@@ -6,7 +6,7 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{{ "Home" if timeline_type == "home" else "Public" }} Timeline</h1> <h1 class="text-2xl font-bold">{{ "Home" if timeline_type == "home" else "Public" }} Timeline</h1>
{% if actor %} {% if actor %}
<a href="{{ url_for('social.compose_form') }}" <a href="{{ url_for('social.defpage_compose_form') }}"
class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700"> class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">
Compose Compose
</a> </a>

View File

@@ -99,25 +99,30 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# Setup defpage routes
from sxc.pages import setup_market_pages
setup_market_pages()
from shared.sx.pages import mount_pages
# All markets: / — global view across all pages # All markets: / — global view across all pages
app.register_blueprint( all_markets_bp = register_all_markets()
register_all_markets(), mount_pages(all_markets_bp, "market", names=["all-markets-index"])
url_prefix="/", app.register_blueprint(all_markets_bp, url_prefix="/")
)
# Page markets: /<slug>/ — markets for a single page # Page markets: /<slug>/ — markets for a single page
app.register_blueprint( page_markets_bp = register_page_markets()
register_page_markets(), mount_pages(page_markets_bp, "market", names=["page-markets-index"])
url_prefix="/<slug>", app.register_blueprint(page_markets_bp, url_prefix="/<slug>")
)
# Page admin: /<slug>/admin/ — post-level admin for markets # Page admin: /<slug>/admin/ — post-level admin for markets
app.register_blueprint( page_admin_bp = register_page_admin()
register_page_admin(), mount_pages(page_admin_bp, "market", names=["page-admin"])
url_prefix="/<slug>/admin", app.register_blueprint(page_admin_bp, url_prefix="/<slug>/admin")
)
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/ # Market blueprint nested under post slug: /<page_slug>/<market_slug>/
# Defpages for market-home and market-admin are mounted inside their
# respective nested blueprints (browse and admin register functions).
app.register_blueprint( app.register_blueprint(
register_market_bp( register_market_bp(
url_prefix="/", url_prefix="/",

View File

@@ -2,70 +2,57 @@
All-markets blueprint — shows markets across ALL pages. All-markets blueprint — shows markets across ALL pages.
Mounted at / (root of market app). No slug context. Mounted at / (root of market app). No slug context.
GET / handled by defpage. GET /all-markets is pagination fragment.
Routes:
GET / — full page with first page of markets
GET /all-markets — HTMX fragment for infinite scroll
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, 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 shared.sx.helpers import sx_response
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict from shared.contracts.dtos import PostDTO, dto_from_dict
from shared.services.registry import services from shared.services.registry import services
async def _load_markets(page, per_page=20):
"""Load all markets + page info for container badges."""
markets, has_more = await services.market.list_marketplaces(
g.s, page=page, per_page=per_page,
)
# Batch-load page info for container_ids
page_info = {}
if markets:
post_ids = list({
m.container_id for m in markets
if m.container_type == "page"
})
if post_ids:
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_p in raw_posts:
p = dto_from_dict(PostDTO, raw_p)
page_info[p.id] = {"title": p.title, "slug": p.slug}
return markets, has_more, page_info
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("all_markets", __name__) bp = Blueprint("all_markets", __name__)
async def _load_markets(page, per_page=20): @bp.before_request
"""Load all markets + page info for container badges.""" async def _prepare_page_data():
markets, has_more = await services.market.list_marketplaces( """Load all-markets data for defpage routes."""
g.s, page=page, per_page=per_page, endpoint = request.endpoint or ""
) if not endpoint.endswith("defpage_all_markets_index"):
return
# Batch-load page info for container_ids
page_info = {}
if markets:
post_ids = list({
m.container_id for m in markets
if m.container_type == "page"
})
if post_ids:
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_p in raw_posts:
p = dto_from_dict(PostDTO, raw_p)
page_info[p.id] = {"title": p.title, "slug": p.slug}
return markets, has_more, page_info
@bp.get("/")
async def index():
page = int(request.args.get("page", 1)) page = int(request.args.get("page", 1))
markets, has_more, page_info = await _load_markets(page) markets, has_more, page_info = await _load_markets(page)
g.all_markets_data = {
ctx = dict( "markets": markets, "has_more": has_more,
markets=markets, "page_info": page_info, "page": page,
has_more=has_more, }
page_info=page_info,
page=page,
)
from shared.sx.page import get_template_context
from sx.sx_components import render_all_markets_page, render_all_markets_oob
tctx = await get_template_context()
if is_htmx_request():
sx_src = await render_all_markets_oob(tctx, markets, has_more, page_info, page)
return sx_response(sx_src)
else:
html = await render_all_markets_page(tctx, markets, has_more, page_info, page)
return await make_response(html, 200)
@bp.get("/all-markets") @bp.get("/all-markets")
async def markets_fragment(): async def markets_fragment():

View File

@@ -5,17 +5,13 @@ from quart import (
g, g,
Blueprint, Blueprint,
abort, abort,
render_template,
render_template_string,
make_response, make_response,
current_app,
) )
from shared.config import config from shared.config import config
from .services.nav import category_context, get_nav from .services.nav import category_context, get_nav
from .services.blacklist.category import is_category_blocked from .services.blacklist.category import is_category_blocked
from .services import ( from .services import (
_hx_fragment_request,
_productInfo, _productInfo,
_vary, _vary,
_current_url_without_page, _current_url_without_page,
@@ -33,27 +29,9 @@ def register():
register_product(), register_product(),
) )
@browse_bp.get("/") # Mount defpage for market home (GET /)
@cache_page(tag="browse") from shared.sx.pages import mount_pages
async def home(): mount_pages(browse_bp, "market", names=["market-home"])
"""
Market landing page.
Uses the post data hydrated by the app-level before_request (g.post_data).
"""
p_data = getattr(g, "post_data", None) or {}
# Determine which template to use based on request type
from shared.sx.page import get_template_context
from sx.sx_components import render_market_home_page, render_market_home_oob
ctx = await get_template_context()
ctx.update(p_data)
if not is_htmx_request():
html = await render_market_home_page(ctx)
return await make_response(html)
else:
sx_src = await render_market_home_oob(ctx)
return sx_response(sx_src)
@browse_bp.get("/all/") @browse_bp.get("/all/")
@cache_page(tag="browse") @cache_page(tag="browse")

View File

@@ -1,31 +1,13 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import Blueprint
render_template, make_response, Blueprint
)
from shared.browser.app.authz import require_admin
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
# ---------- Pages ----------
@bp.get("/")
@require_admin
async def admin():
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.page import get_template_context # Mount defpage for market admin (GET /)
from sx.sx_components import render_market_admin_page, render_market_admin_oob from shared.sx.pages import mount_pages
mount_pages(bp, "market", names=["market-admin"])
tctx = await get_template_context()
if not is_htmx_request():
html = await render_market_admin_page(tctx)
return await make_response(html)
else:
from shared.sx.helpers import sx_response
sx_src = await render_market_admin_oob(tctx)
return sx_response(sx_src)
return bp return bp

View File

@@ -6,7 +6,6 @@ import unicodedata
from quart import make_response, request, g, Blueprint from quart import make_response, request, g, Blueprint
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.services.registry import services from shared.services.registry import services
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -27,19 +26,16 @@ def _slugify(value: str, max_len: int = 255) -> str:
def register(): def register():
bp = Blueprint("page_admin", __name__) bp = Blueprint("page_admin", __name__)
@bp.get("/") @bp.before_request
@require_admin async def _prepare_page_data():
async def admin(**kwargs): """Pre-render page admin content for defpage (async helper)."""
endpoint = request.endpoint or ""
if request.method != "GET" or not endpoint.endswith("defpage_page_admin"):
return
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_page_admin_page, render_page_admin_oob from sx.sx_components import _markets_admin_panel_sx
ctx = await get_template_context()
tctx = await get_template_context() g.page_admin_content = await _markets_admin_panel_sx(ctx)
if not is_htmx_request():
html = await render_page_admin_page(tctx)
return await make_response(html)
else:
sx_src = await render_page_admin_oob(tctx)
return sx_response(sx_src)
@bp.post("/new/") @bp.post("/new/")
@require_admin @require_admin

View File

@@ -2,55 +2,40 @@
Page-markets blueprint — shows markets for a single page. Page-markets blueprint — shows markets for a single page.
Mounted at /<slug> (page-scoped). Requires g.post_data from hydrate_post. Mounted at /<slug> (page-scoped). Requires g.post_data from hydrate_post.
GET / handled by defpage. GET /page-markets is pagination fragment.
Routes:
GET /<slug>/ — full page scoped to this page
GET /<slug>/page-markets — HTMX fragment for infinite scroll
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, request, 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 shared.sx.helpers import sx_response
from shared.services.registry import services from shared.services.registry import services
async def _load_markets(post_id, page, per_page=20):
"""Load markets for this page's container."""
markets, has_more = await services.market.list_marketplaces(
g.s, "page", post_id, page=page, per_page=per_page,
)
return markets, has_more
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("page_markets", __name__) bp = Blueprint("page_markets", __name__)
async def _load_markets(post_id, page, per_page=20): @bp.before_request
"""Load markets for this page's container.""" async def _prepare_page_data():
markets, has_more = await services.market.list_marketplaces( """Load page-markets data for defpage routes."""
g.s, "page", post_id, page=page, per_page=per_page, endpoint = request.endpoint or ""
) if not endpoint.endswith("defpage_page_markets_index"):
return markets, has_more return
@bp.get("/")
async def index():
post = g.post_data["post"] post = g.post_data["post"]
page = int(request.args.get("page", 1)) page = int(request.args.get("page", 1))
markets, has_more = await _load_markets(post["id"], page) markets, has_more = await _load_markets(post["id"], page)
g.page_markets_data = {
ctx = dict( "markets": markets, "has_more": has_more,
markets=markets, "page": page, "post_slug": post.get("slug", ""),
has_more=has_more, }
page_info={},
page=page,
)
from shared.sx.page import get_template_context
from sx.sx_components import render_page_markets_page, render_page_markets_oob
tctx = await get_template_context()
tctx["post"] = post
if is_htmx_request():
sx_src = await render_page_markets_oob(tctx, markets, has_more, page)
return sx_response(sx_src)
else:
html = await render_page_markets_page(tctx, markets, has_more, page)
return await make_response(html, 200)
@bp.get("/page-markets") @bp.get("/page-markets")
async def markets_fragment(): async def markets_fragment():

View File

@@ -111,7 +111,7 @@ def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
sub_div=SxExpr(sub_div) if sub_div else None, sub_div=SxExpr(sub_div) if sub_div else None,
) )
link_href = url_for("market.browse.home") link_href = url_for("market.browse.defpage_market_home")
# Build desktop nav from categories # Build desktop nav from categories
categories = ctx.get("categories", {}) categories = ctx.get("categories", {})
@@ -159,7 +159,7 @@ def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str,
admin_sx = "" admin_sx = ""
if rights and rights.get("admin"): if rights and rights.get("admin"):
admin_href = prefix + url_for("market.admin.admin") admin_href = prefix + url_for("market.admin.defpage_market_admin")
admin_sx = sx_call("market-admin-link", href=admin_href, hx_select=hx_select) admin_sx = sx_call("market-admin-link", href=admin_href, hx_select=hx_select)
return sx_call("market-desktop-category-nav", return sx_call("market-desktop-category-nav",
@@ -1203,46 +1203,6 @@ def _no_markets_sx(message: str = "No markets available") -> str:
cls="px-3 py-12 text-center text-stone-400") cls="px-3 py-12 text-center text-stone-400")
async def render_all_markets_page(ctx: dict, markets: list, has_more: bool,
page_info: dict, page: int) -> str:
"""Full page: all markets listing."""
from quart import url_for
from shared.utils import route_prefix
prefix = route_prefix()
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
if markets:
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
content = _markets_grid(cards)
else:
content = _no_markets_sx()
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_all_markets_oob(ctx: dict, markets: list, has_more: bool,
page_info: dict, page: int) -> str:
"""OOB response: all markets listing."""
from quart import url_for
from shared.utils import route_prefix
prefix = route_prefix()
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
if markets:
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
content = _markets_grid(cards)
else:
content = _no_markets_sx()
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
oobs = root_header_sx(ctx, oob=True)
return oob_page_sx(oobs=oobs, content=content)
async def render_all_markets_cards(markets: list, has_more: bool, async def render_all_markets_cards(markets: list, has_more: bool,
page_info: dict, page: int) -> str: page_info: dict, page: int) -> str:
"""Pagination fragment: all markets cards.""" """Pagination fragment: all markets cards."""
@@ -1258,54 +1218,6 @@ async def render_all_markets_cards(markets: list, has_more: bool,
# Page markets # Page markets
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def render_page_markets_page(ctx: dict, markets: list, has_more: bool,
page: int) -> str:
"""Full page: page-scoped markets listing."""
from quart import url_for
from shared.utils import route_prefix
prefix = route_prefix()
post = ctx.get("post", {})
post_slug = post.get("slug", "")
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
if markets:
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
hdr = root_header_sx(ctx)
hdr = "(<> " + hdr + " " + header_child_sx(_post_header_sx(ctx)) + ")"
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_page_markets_oob(ctx: dict, markets: list, has_more: bool,
page: int) -> str:
"""OOB response: page-scoped markets."""
from quart import url_for
from shared.utils import route_prefix
prefix = route_prefix()
post = ctx.get("post", {})
post_slug = post.get("slug", "")
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
if markets:
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
content = "(<> " + content + " " + '(div :class "pb-8")' + ")"
oobs = _oob_header_sx("post-header-child", "market-header-child", "")
oobs = "(<> " + oobs + " " + _post_header_sx(ctx, oob=True) + ")"
return oob_page_sx(oobs=oobs, content=content)
async def render_page_markets_cards(markets: list, has_more: bool, async def render_page_markets_cards(markets: list, has_more: bool,
page: int, post_slug: str) -> str: page: int, post_slug: str) -> str:
"""Pagination fragment: page-scoped markets cards.""" """Pagination fragment: page-scoped markets cards."""
@@ -1322,31 +1234,6 @@ async def render_page_markets_cards(markets: list, has_more: bool,
# Market landing page # Market landing page
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def render_market_home_page(ctx: dict) -> str:
"""Full page: market landing page (post content)."""
post = ctx.get("post") or {}
content = _market_landing_content_sx(post)
hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + ")"
hdr = "(<> " + hdr + " " + header_child_sx(child) + ")"
menu = _mobile_nav_panel_sx(ctx)
return full_page_sx(ctx, header_rows=hdr, content=content, menu=menu)
async def render_market_home_oob(ctx: dict) -> str:
"""OOB response: market landing page."""
post = ctx.get("post") or {}
content = _market_landing_content_sx(post)
oobs = _oob_header_sx("post-header-child", "market-header-child",
_market_header_sx(ctx))
oobs = "(<> " + oobs + " " + _post_header_sx(ctx, oob=True) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child") + ")"
menu = _mobile_nav_panel_sx(ctx)
return oob_page_sx(oobs=oobs, content=content, menu=menu)
def _market_landing_content_sx(post: dict) -> str: def _market_landing_content_sx(post: dict) -> str:
"""Build market landing page content as sx.""" """Build market landing page content as sx."""
@@ -1485,29 +1372,6 @@ def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
# Market admin # Market admin
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def render_market_admin_page(ctx: dict) -> str:
"""Full page: market admin."""
content = '"market admin"'
hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + " "
child += _market_admin_header_sx(ctx, selected="markets") + ")"
hdr = "(<> " + hdr + " " + header_child_sx(child) + ")"
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_market_admin_oob(ctx: dict) -> str:
"""OOB response: market admin."""
content = '"market admin"'
oobs = "(<> " + _market_header_sx(ctx, oob=True) + " "
oobs += _oob_header_sx("market-header-child", "market-admin-header-child",
_market_admin_header_sx(ctx, selected="markets")) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"market-admin-row", "market-admin-header-child") + ")"
return oob_page_sx(oobs=oobs, content=content)
def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str: def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
"""Build market admin header row — delegates to shared helper.""" """Build market admin header row — delegates to shared helper."""
@@ -1586,28 +1450,6 @@ async def render_markets_admin_list_panel(ctx: dict) -> str:
return await _markets_admin_panel_sx(ctx) return await _markets_admin_panel_sx(ctx)
async def render_page_admin_page(ctx: dict) -> str:
"""Full page: page-level market admin."""
slug = (ctx.get("post") or {}).get("slug", "")
admin_hdr = post_admin_header_sx(ctx, slug, selected="markets")
hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + admin_hdr + ")"
hdr = "(<> " + hdr + " " + header_child_sx(child) + ")"
content = await _markets_admin_panel_sx(ctx)
content = '(div :id "main-panel" ' + content + ')'
return full_page_sx(ctx, header_rows=hdr, content=content)
async def render_page_admin_oob(ctx: dict) -> str:
"""OOB response: page-level market admin."""
slug = (ctx.get("post") or {}).get("slug", "")
oobs = "(<> " + post_admin_header_sx(ctx, slug, oob=True, selected="markets") + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"post-admin-row", "post-admin-header-child") + ")"
content = await _markets_admin_panel_sx(ctx)
content = '(div :id "main-panel" ' + content + ')'
return oob_page_sx(oobs=oobs, content=content)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API: POST handler fragment renderers # Public API: POST handler fragment renderers

0
market/sxc/__init__.py Normal file
View File

View File

@@ -0,0 +1,170 @@
"""Market defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_market_pages() -> None:
"""Register market-specific layouts, page helpers, and load page definitions."""
_register_market_layouts()
_register_market_helpers()
_load_market_page_files()
def _load_market_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "market")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_market_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("market", _market_full, _market_oob, _market_mobile)
register_custom_layout("market-admin", _market_admin_full, _market_admin_oob)
def _market_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _post_header_sx, _market_header_sx
root_hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + ")"
return "(<> " + root_hdr + " " + header_child_sx(child) + ")"
def _market_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _post_header_sx, _market_header_sx, _clear_deeper_oob
oobs = oob_header_sx("post-header-child", "market-header-child",
_market_header_sx(ctx))
oobs = "(<> " + oobs + " " + _post_header_sx(ctx, oob=True) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child") + ")"
return oobs
def _market_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _mobile_nav_panel_sx
return _mobile_nav_panel_sx(ctx)
def _market_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import (
_post_header_sx, _market_header_sx, _market_admin_header_sx,
)
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
child = "(<> " + _post_header_sx(ctx) + " " + _market_header_sx(ctx) + " "
child += _market_admin_header_sx(ctx, selected=selected) + ")"
return "(<> " + root_hdr + " " + header_child_sx(child) + ")"
def _market_admin_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import (
_market_header_sx, _market_admin_header_sx, _clear_deeper_oob,
)
selected = kw.get("selected", "")
oobs = "(<> " + _market_header_sx(ctx, oob=True) + " "
oobs += oob_header_sx("market-header-child", "market-admin-header-child",
_market_admin_header_sx(ctx, selected=selected)) + " "
oobs += _clear_deeper_oob("post-row", "post-header-child",
"market-row", "market-header-child",
"market-admin-row", "market-admin-header-child") + ")"
return oobs
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_market_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("market", {
"all-markets-content": _h_all_markets_content,
"page-markets-content": _h_page_markets_content,
"page-admin-content": _h_page_admin_content,
"market-home-content": _h_market_home_content,
"market-admin-content": _h_market_admin_content,
})
def _h_all_markets_content():
from quart import g, url_for, request
from shared.utils import route_prefix
data = getattr(g, "all_markets_data", None)
if not data:
from sx.sx_components import _no_markets_sx
return _no_markets_sx()
markets = data["markets"]
has_more = data["has_more"]
page_info = data["page_info"]
page = data["page"]
prefix = route_prefix()
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx
if markets:
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
content = _markets_grid(cards)
else:
content = _no_markets_sx()
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_markets_content():
from quart import g, url_for
from shared.utils import route_prefix
data = getattr(g, "page_markets_data", None)
if not data:
from sx.sx_components import _no_markets_sx
return _no_markets_sx("No markets for this page")
markets = data["markets"]
has_more = data["has_more"]
page = data["page"]
post_slug = data.get("post_slug", "")
prefix = route_prefix()
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
from sx.sx_components import _market_cards_sx, _markets_grid, _no_markets_sx
if markets:
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = _markets_grid(cards)
else:
content = _no_markets_sx("No markets for this page")
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_admin_content():
# Content pre-rendered by before_request (async _markets_admin_panel_sx)
from quart import g
content = getattr(g, "page_admin_content", "")
return '(div :id "main-panel" ' + content + ')'
def _h_market_home_content():
from quart import g
post_data = getattr(g, "post_data", {})
post = post_data.get("post", {})
from sx.sx_components import _market_landing_content_sx
return _market_landing_content_sx(post)
def _h_market_admin_content():
return '"market admin"'

View File

@@ -0,0 +1,37 @@
;; Market app defpage declarations.
;;
;; all-markets-index: / — global view across all pages
;; page-markets-index: / (on page_markets bp, mounted at /<slug>)
;; page-admin: / (on page_admin bp, mounted at /<slug>/admin)
;; market-home: / (on browse bp, mounted at /<page_slug>/<market_slug>)
;; market-admin: / (on admin bp, mounted at /<page_slug>/<market_slug>/admin)
(defpage all-markets-index
:path "/"
:auth :public
:layout :root
:content (all-markets-content))
(defpage page-markets-index
:path "/"
:auth :public
:layout :post
:content (page-markets-content))
(defpage page-admin
:path "/"
:auth :admin
:layout (:post-admin :selected "markets")
:content (page-admin-content))
(defpage market-home
:path "/"
:auth :public
:layout :market
:content (market-home-content))
(defpage market-admin
:path "/"
:auth :admin
:layout (:market-admin :selected "markets")
:content (market-admin-content))

View File

@@ -70,16 +70,22 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# Load orders-specific s-expression components # Load orders-specific s-expression components (loaded at import time)
from sx.sx_components import load_orders_components import sx.sx_components # noqa: F811
load_orders_components()
# Setup defpage routes
from sxc.pages import setup_orders_pages
setup_orders_pages()
app.register_blueprint(register_fragments()) app.register_blueprint(register_fragments())
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
# Orders list at / # Orders list at / (defpage routes mounted below)
app.register_blueprint(register_orders(url_prefix="/")) bp = register_orders(url_prefix="/")
from shared.sx.pages import mount_pages
mount_pages(bp, "orders")
app.register_blueprint(bp)
# Checkout webhook + return # Checkout webhook + return
app.register_blueprint(register_checkout()) app.register_blueprint(register_checkout())

View File

@@ -2,16 +2,13 @@ from __future__ import annotations
from quart import Blueprint, g, redirect, url_for, make_response from quart import Blueprint, g, redirect, url_for, make_response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.order import Order from shared.models.order import Order
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config from shared.config import config
from shared.infrastructure.cart_identity import current_cart_identity from shared.infrastructure.cart_identity import current_cart_identity
from shared.sx.page import get_template_context
from services.check_sumup_status import check_sumup_status from services.check_sumup_status import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from .filters.qs import makeqs_factory, decode from .filters.qs import makeqs_factory, decode
@@ -33,34 +30,6 @@ def register() -> Blueprint:
def route(): def route():
g.makeqs_factory = makeqs_factory g.makeqs_factory = makeqs_factory
@bp.get("/")
async def order_detail(order_id: int):
"""Show a single order + items."""
owner = _owner_filter()
if owner is None:
return await make_response("Order not found", 404)
result = await g.s.execute(
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
from sx.sx_components import render_order_page, render_order_oob
ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries")
if not is_htmx_request():
html = await render_order_page(ctx, order, calendar_entries, url_for)
return await make_response(html)
else:
from shared.sx.helpers import sx_response
sx_src = await render_order_oob(ctx, order, calendar_entries, url_for)
return sx_response(sx_src)
@bp.get("/pay/") @bp.get("/pay/")
async def order_pay(order_id: int): async def order_pay(order_id: int):
"""Re-open the SumUp payment page for this order.""" """Re-open the SumUp payment page for this order."""
@@ -73,7 +42,7 @@ def register() -> Blueprint:
return await make_response("Order not found", 404) return await make_response("Order not found", 404)
if order.status == "paid": if order.status == "paid":
return redirect(url_for("orders.order.order_detail", order_id=order.id)) return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
if order.sumup_hosted_url: if order.sumup_hosted_url:
return redirect(order.sumup_hosted_url) return redirect(order.sumup_hosted_url)
@@ -120,13 +89,13 @@ def register() -> Blueprint:
return await make_response("Order not found", 404) return await make_response("Order not found", 404)
if not order.sumup_checkout_id: if not order.sumup_checkout_id:
return redirect(url_for("orders.order.order_detail", order_id=order.id)) return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
try: try:
await check_sumup_status(g.s, order) await check_sumup_status(g.s, order)
except Exception: except Exception:
pass pass
return redirect(url_for("orders.order.order_detail", order_id=order.id)) return redirect(url_for("orders.defpage_order_detail", order_id=order.id))
return bp return bp

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response from quart import Blueprint, g, redirect, url_for, make_response, request
from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -20,18 +20,6 @@ def register(url_prefix: str) -> Blueprint:
ORDERS_PER_PAGE = 10 ORDERS_PER_PAGE = 10
oob = {
"extends": "_types/root/_index.html",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html",
}
@bp.context_processor
def inject_oob():
return {"oob": oob}
@bp.before_request @bp.before_request
def route(): def route():
g.makeqs_factory = makeqs_factory g.makeqs_factory = makeqs_factory
@@ -43,8 +31,115 @@ def register(url_prefix: str) -> Blueprint:
if not ident["user_id"] and not ident["session_id"]: if not ident["user_id"] and not ident["session_id"]:
return redirect(url_for("auth.login_form")) return redirect(url_for("auth.login_form"))
@bp.get("/") @bp.before_request
async def list_orders(): async def _prepare_page_data():
"""Load data for defpage routes into g.*."""
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Orders list page
if endpoint.endswith("defpage_orders_list"):
ident = current_cart_identity()
if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner_clause = Order.session_id == ident["session_id"]
else:
return
q = decode()
page, search = q.page, q.search
if page < 1:
page = 1
where_clause = _search_clause(search) if search else None
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
if page > total_pages:
page = total_pages
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order)
.where(owner_clause)
.order_by(Order.created_at.desc())
.offset(offset)
.limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
from shared.utils import route_prefix
pfx = route_prefix()
qs_fn = makeqs_factory()
g.orders_page_data = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count,
"url_for_fn": url_for,
"qs_fn": qs_fn,
"list_url": pfx + url_for("orders.defpage_orders_list"),
}
# Order detail page
elif endpoint.endswith("defpage_order_detail"):
order_id = request.view_args.get("order_id")
if order_id is None:
return
ident = current_cart_identity()
if ident["user_id"]:
owner = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner = Order.session_id == ident["session_id"]
else:
from quart import abort
abort(404)
return
result = await g.s.execute(
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
from quart import abort
abort(404)
return
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
g.order_detail_data = {
"order": order,
"calendar_entries": None,
"detail_url": pfx + url_for("orders.defpage_order_detail", order_id=order.id),
"list_url": pfx + url_for("orders.defpage_orders_list"),
"recheck_url": pfx + url_for("orders.order.order_recheck", order_id=order.id),
"pay_url": pfx + url_for("orders.order.order_pay", order_id=order.id),
"csrf_token": generate_csrf_token(),
}
@bp.get("/rows")
async def orders_rows():
"""Pagination endpoint — returns order rows for page > 1."""
ident = current_cart_identity() ident = current_cart_identity()
if ident["user_id"]: if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"] owner_clause = Order.user_id == ident["user_id"]
@@ -58,38 +153,7 @@ def register(url_prefix: str) -> Blueprint:
if page < 1: if page < 1:
page = 1 page = 1
where_clause = None where_clause = _search_clause(search) if search else None
if search:
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1)
.select_from(OrderItem)
.where(
OrderItem.order_id == Order.id,
or_(
OrderItem.product_title.ilike(term),
OrderItem.product_slug.ilike(term),
),
)
)
)
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
where_clause = or_(*conditions)
count_stmt = select(func.count()).select_from(Order).where(owner_clause) count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None: if where_clause is not None:
@@ -116,38 +180,47 @@ def register(url_prefix: str) -> Blueprint:
result = await g.s.execute(stmt) result = await g.s.execute(stmt)
orders = result.scalars().all() orders = result.scalars().all()
from shared.sx.page import get_template_context from sx.sx_components import _orders_rows_sx
from sx.sx_components import ( from shared.sx.helpers import sx_response
render_orders_page,
render_orders_rows,
render_orders_oob,
)
ctx = await get_template_context()
qs_fn = makeqs_factory() qs_fn = makeqs_factory()
sx_src = _orders_rows_sx(orders, page, total_pages, url_for, qs_fn)
if not is_htmx_request(): resp = sx_response(sx_src)
html = await render_orders_page(
ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn,
)
resp = await make_response(html)
elif page > 1:
# Sx wire format — client renders order rows
from shared.sx.helpers import sx_response
sx_src = await render_orders_rows(
ctx, orders, page, total_pages, url_for, qs_fn,
)
resp = sx_response(sx_src)
else:
from shared.sx.helpers import sx_response
sx_src = await render_orders_oob(
ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn,
)
resp = sx_response(sx_src)
resp.headers["Hx-Push-Url"] = _current_url_without_page() resp.headers["Hx-Push-Url"] = _current_url_without_page()
return _vary(resp) return _vary(resp)
return bp return bp
def _search_clause(search: str):
"""Build an OR search clause across order fields."""
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1)
.select_from(OrderItem)
.where(
OrderItem.order_id == Order.id,
or_(
OrderItem.product_title.ilike(term),
OrderItem.product_slug.ilike(term),
),
)
)
)
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
return or_(*conditions)

View File

@@ -12,10 +12,8 @@ from typing import Any
from shared.sx.jinja_bridge import load_service_components from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import ( from shared.sx.helpers import (
call_url, root_header_sx, call_url,
full_page_sx, header_child_sx, oob_page_sx,
sx_call, SxExpr, sx_call, SxExpr,
search_mobile_sx, search_desktop_sx,
) )
from shared.infrastructure.urls import market_product_url, cart_url from shared.infrastructure.urls import market_product_url, cart_url
@@ -103,7 +101,7 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
parts = [] parts = []
for o in orders: for o in orders:
d = _order_row_data(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id)) d = _order_row_data(o, pfx + url_for_fn("orders.defpage_order_detail", order_id=o.id))
parts.append(sx_call("order-row-desktop", parts.append(sx_call("order-row-desktop",
oid=d["oid"], created=d["created"], oid=d["oid"], created=d["created"],
desc=d["desc"], total=d["total"], desc=d["desc"], total=d["total"],
@@ -115,7 +113,7 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
status=d["status"], url=d["url"])) status=d["status"], url=d["url"]))
if page < total_pages: if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) next_url = pfx + url_for_fn("orders.orders_rows") + qs_fn(page=page + 1)
parts.append(sx_call("infinite-scroll", parts.append(sx_call("infinite-scroll",
url=next_url, page=page, url=next_url, page=page,
total_pages=total_pages, total_pages=total_pages,
@@ -143,63 +141,8 @@ def _orders_summary_sx(ctx: dict) -> str:
# Public API: orders list # Public API: orders list
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def render_orders_page(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Full page: orders list (sx wire format)."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
hdr = root_header_sx(ctx)
inner = "(<> " + _auth_header_sx(ctx) + " " + _orders_header_sx(ctx, list_url) + ")"
hdr = "(<> " + hdr + " " + header_child_sx(inner) + ")"
return full_page_sx(ctx, header_rows=hdr,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
async def render_orders_rows(ctx: dict, orders: list, page: int,
total_pages: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Pagination: just the table rows (sx wire format)."""
return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
async def render_orders_oob(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""OOB response for HTMX navigation to orders list (sx)."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
auth_hdr = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call("oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)))
root_hdr = root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_hdr + " " + auth_child_oob + " " + root_hdr + ")"
return oob_page_sx(oobs=oobs,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -300,68 +243,8 @@ def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
) )
async def render_order_page(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""Full page: single order detail (sx wire format)."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
# Header stack: root -> auth -> orders -> order
hdr = root_header_sx(ctx)
order_row = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp",
)
detail_header = sx_call(
"order-detail-header-stack",
auth=SxExpr(_auth_header_sx(ctx)),
orders=SxExpr(_orders_header_sx(ctx, list_url)),
order=SxExpr(order_row),
)
hdr = "(<> " + hdr + " " + detail_header + ")"
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=main)
async def render_order_oob(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""OOB response for single order detail (sx)."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp", oob=True,
)
header_child_oob = sx_call("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_hdr = root_header_sx(ctx, oob=True)
oobs = "(<> " + header_child_oob + " " + root_hdr + ")"
return oob_page_sx(oobs=oobs, filter=filt, content=main)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

0
orders/sxc/__init__.py Normal file
View File

View File

@@ -0,0 +1,201 @@
"""Orders defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_orders_pages() -> None:
"""Register orders-specific layouts, page helpers, and load page definitions."""
_register_orders_layouts()
_register_orders_helpers()
_load_orders_page_files()
def _load_orders_page_files() -> None:
"""Load defpage definitions from orders/sxc/pages/*.sx."""
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "orders")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_orders_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("orders", _orders_full, _orders_oob, _orders_mobile)
register_custom_layout("order-detail", _order_detail_full, _order_detail_oob, _order_detail_mobile)
def _orders_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, _orders_header_sx
list_url = kw.get("list_url", "/")
root_hdr = root_header_sx(ctx)
inner = "(<> " + _auth_header_sx(ctx) + " " + _orders_header_sx(ctx, list_url) + ")"
return "(<> " + root_hdr + " " + header_child_sx(inner) + ")"
def _orders_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _auth_header_sx, _orders_header_sx
list_url = kw.get("list_url", "/")
auth_hdr = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call("oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)))
root_hdr = root_header_sx(ctx, oob=True)
return "(<> " + auth_hdr + " " + auth_child_oob + " " + root_hdr + ")"
def _orders_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx
return mobile_menu_sx(mobile_root_nav_sx(ctx))
def _order_detail_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _auth_header_sx, _orders_header_sx
list_url = kw.get("list_url", "/")
detail_url = kw.get("detail_url", "/")
root_hdr = root_header_sx(ctx)
order_row = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp",
)
detail_header = sx_call(
"order-detail-header-stack",
auth=SxExpr(_auth_header_sx(ctx)),
orders=SxExpr(_orders_header_sx(ctx, list_url)),
order=SxExpr(order_row),
)
return "(<> " + root_hdr + " " + detail_header + ")"
def _order_detail_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
detail_url = kw.get("detail_url", "/")
order_row_oob = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky", link_href=detail_url,
link_label="Order", icon="fa fa-gbp", oob=True,
)
header_child_oob = sx_call("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_hdr = root_header_sx(ctx, oob=True)
return "(<> " + header_child_oob + " " + root_hdr + ")"
def _order_detail_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx
return mobile_menu_sx(mobile_root_nav_sx(ctx))
# ---------------------------------------------------------------------------
# Page helpers — Python functions callable from defpage expressions
# ---------------------------------------------------------------------------
def _register_orders_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("orders", {
# Orders list
"orders-list-content": _h_orders_list_content,
"orders-list-filter": _h_orders_list_filter,
"orders-list-aside": _h_orders_list_aside,
"orders-list-url": _h_orders_list_url,
# Order detail
"order-detail-content": _h_order_detail_content,
"order-detail-filter": _h_order_detail_filter,
"order-detail-url": _h_order_detail_url,
"order-list-url-from-detail": _h_order_list_url_from_detail,
})
def _h_orders_list_content():
from quart import g
d = getattr(g, "orders_page_data", None)
if not d:
from shared.sx.helpers import sx_call
return sx_call("order-empty-state")
from sx.sx_components import _orders_rows_sx, _orders_main_panel_sx
rows = _orders_rows_sx(d["orders"], d["page"], d["total_pages"],
d["url_for_fn"], d["qs_fn"])
return _orders_main_panel_sx(d["orders"], rows)
def _h_orders_list_filter():
from quart import g
from shared.sx.helpers import sx_call, SxExpr
from shared.sx.page import SEARCH_HEADERS_MOBILE
d = getattr(g, "orders_page_data", None)
search = d.get("search", "") if d else ""
search_count = d.get("search_count", "") if d else ""
search_mobile = sx_call("search-mobile",
current_local_href="/",
search=search or "",
search_count=search_count or "",
hx_select="#main-panel",
search_headers_mobile=SEARCH_HEADERS_MOBILE,
)
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile))
def _h_orders_list_aside():
from quart import g
from shared.sx.helpers import sx_call
from shared.sx.page import SEARCH_HEADERS_DESKTOP
d = getattr(g, "orders_page_data", None)
search = d.get("search", "") if d else ""
search_count = d.get("search_count", "") if d else ""
return sx_call("search-desktop",
current_local_href="/",
search=search or "",
search_count=search_count or "",
hx_select="#main-panel",
search_headers_desktop=SEARCH_HEADERS_DESKTOP,
)
def _h_orders_list_url():
from quart import g
d = getattr(g, "orders_page_data", None)
return d["list_url"] if d else "/"
def _h_order_detail_content():
from quart import g
d = getattr(g, "order_detail_data", None)
if not d:
return ""
from sx.sx_components import _order_main_sx
return _order_main_sx(d["order"], d["calendar_entries"])
def _h_order_detail_filter():
from quart import g
d = getattr(g, "order_detail_data", None)
if not d:
return ""
from sx.sx_components import _order_filter_sx
return _order_filter_sx(d["order"], d["list_url"], d["recheck_url"],
d["pay_url"], d["csrf_token"])
def _h_order_detail_url():
from quart import g
d = getattr(g, "order_detail_data", None)
return d["detail_url"] if d else "/"
def _h_order_list_url_from_detail():
from quart import g
d = getattr(g, "order_detail_data", None)
return d["list_url"] if d else "/"

View File

@@ -0,0 +1,27 @@
;; Orders app — declarative page definitions
;; ---------------------------------------------------------------------------
;; Orders list
;; ---------------------------------------------------------------------------
(defpage orders-list
:path "/"
:auth :public
:layout (:orders
:list-url (orders-list-url))
:filter (orders-list-filter)
:aside (orders-list-aside)
:content (orders-list-content))
;; ---------------------------------------------------------------------------
;; Order detail
;; ---------------------------------------------------------------------------
(defpage order-detail
:path "/<int:order_id>/"
:auth :public
:layout (:order-detail
:list-url (order-list-url-from-detail)
:detail-url (order-detail-url))
:filter (order-detail-filter)
:content (order-detail-content))

View File

@@ -33,7 +33,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, RelationDef, Symbol from .types import Component, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol
from .primitives import _PRIMITIVES from .primitives import _PRIMITIVES
@@ -635,6 +635,85 @@ def _sf_defrelation(expr: list, env: dict) -> RelationDef:
return defn return defn
def _sf_defpage(expr: list, env: dict) -> PageDef:
"""``(defpage name :path "/..." :auth :public :content expr ...)``
Parses keyword args from the expression. All slot values are stored
as unevaluated AST — they are resolved at request time by execute_page().
"""
if len(expr) < 2:
raise EvalError("defpage requires a name")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defpage name must be symbol, got {type(name_sym).__name__}")
# Parse keyword args — values are NOT evaluated (stored as AST)
slots: dict[str, Any] = {}
i = 2
while i < len(expr):
key = expr[i]
if isinstance(key, Keyword) and i + 1 < len(expr):
slots[key.name] = expr[i + 1]
i += 2
else:
i += 1
# Required fields
path = slots.get("path")
if path is None:
raise EvalError(f"defpage {name_sym.name} missing required :path")
if not isinstance(path, str):
raise EvalError(f"defpage {name_sym.name} :path must be a string")
auth_val = slots.get("auth", "public")
if isinstance(auth_val, Keyword):
auth: str | list = auth_val.name
elif isinstance(auth_val, list):
# (:rights "a" "b") → ["rights", "a", "b"]
auth = []
for item in auth_val:
if isinstance(item, Keyword):
auth.append(item.name)
elif isinstance(item, str):
auth.append(item)
else:
auth.append(_eval(item, env))
else:
auth = str(auth_val) if auth_val else "public"
# Layout — keep unevaluated
layout = slots.get("layout")
if isinstance(layout, Keyword):
layout = layout.name
elif isinstance(layout, list):
# Keep as unevaluated list for execute_page to resolve at request time
pass
# Cache — evaluate if present (it's a static config dict)
cache_val = slots.get("cache")
cache = None
if cache_val is not None:
cache_result = _eval(cache_val, env)
if isinstance(cache_result, dict):
cache = cache_result
page = PageDef(
name=name_sym.name,
path=path,
auth=auth,
layout=layout,
cache=cache,
data_expr=slots.get("data"),
content_expr=slots.get("content"),
filter_expr=slots.get("filter"),
aside_expr=slots.get("aside"),
menu_expr=slots.get("menu"),
closure=dict(env),
)
env[f"page:{name_sym.name}"] = page
return page
_SPECIAL_FORMS: dict[str, Any] = { _SPECIAL_FORMS: dict[str, Any] = {
"if": _sf_if, "if": _sf_if,
"when": _sf_when, "when": _sf_when,
@@ -657,6 +736,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
"defmacro": _sf_defmacro, "defmacro": _sf_defmacro,
"quasiquote": _sf_quasiquote, "quasiquote": _sf_quasiquote,
"defhandler": _sf_defhandler, "defhandler": _sf_defhandler,
"defpage": _sf_defpage,
} }

View File

@@ -102,26 +102,136 @@ def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
) )
def mobile_nav_sx(ctx: dict) -> str: def mobile_menu_sx(*sections: str) -> str:
"""Build mobile navigation panel from context fragments (nav_tree, auth_menu).""" """Assemble mobile menu from pre-built sections (deepest first)."""
parts = [s for s in sections if s]
return "(<> " + " ".join(parts) + ")" if parts else ""
def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or "" nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or "" auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu: if not nav_tree and not auth_menu:
return "" return ""
return sx_call("mobile-root-nav",
nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu),
)
# ---------------------------------------------------------------------------
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
# ---------------------------------------------------------------------------
def _post_nav_items_sx(ctx: dict) -> str:
"""Build post-level nav items (container_nav + admin cog). Shared by
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
post = ctx.get("post") or {}
slug = post.get("slug", "")
if not slug:
return ""
parts: list[str] = [] parts: list[str] = []
if nav_tree: page_cart_count = ctx.get("page_cart_count", 0)
nav_tree_sx = _as_sx(nav_tree) if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(sx_call("page-cart-badge", href=cart_href,
count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip()
# Skip empty fragment wrappers like "(<> )"
if container_nav and container_nav.replace("(<>", "").replace(")", "").strip():
parts.append( parts.append(
f'(div :class "flex flex-col gap-2 p-3 text-sm" {nav_tree_sx})' f'(div :id "entries-calendars-nav-wrapper"'
) f' :class "flex flex-col sm:flex-row sm:items-center gap-2'
if auth_menu: f' border-r border-stone-200 mr-2 sm:max-w-2xl"'
auth_sx = _as_sx(auth_menu) f' {container_nav})'
parts.append(
f'(div :class "p-3 border-t border-stone-200" {auth_sx})'
) )
# Admin cog
admin_nav = ctx.get("post_admin_nav")
if not admin_nav:
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin and slug:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
if admin_nav:
parts.append(admin_nav)
return "(<> " + " ".join(parts) + ")" if parts else "" return "(<> " + " ".join(parts) + ")" if parts else ""
def _post_admin_nav_items_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Build post-admin nav items (calendars, markets, etc.). Shared by
``post_admin_header_sx`` (desktop) and mobile menu."""
select_colours = ctx.get("select_colours", "")
parts: list[str] = []
items = [
("events_url", f"/{slug}/admin/", "calendars"),
("market_url", f"/{slug}/admin/", "markets"),
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
("blog_url", f"/{slug}/admin/preview/", "preview"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
for url_key, path, label in items:
url_fn = ctx.get(url_key)
if not callable(url_fn):
continue
href = url_fn(path)
is_sel = label == selected
parts.append(sx_call("nav-link", href=href, label=label,
select_colours=select_colours,
is_selected=is_sel or None))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Mobile menu section builders — wrap shared nav items for hamburger panel
# ---------------------------------------------------------------------------
def post_mobile_nav_sx(ctx: dict) -> str:
"""Post-level mobile menu section."""
nav = _post_nav_items_sx(ctx)
if not nav:
return ""
post = ctx.get("post") or {}
slug = post.get("slug", "")
title = (post.get("title") or slug)[:40]
return sx_call("mobile-menu-section",
label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1,
items=SxExpr(nav),
)
def post_admin_mobile_nav_sx(ctx: dict, slug: str,
selected: str = "") -> str:
"""Post-admin mobile menu section."""
nav = _post_admin_nav_items_sx(ctx, slug, selected)
if not nav:
return ""
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
return sx_call("mobile-menu-section",
label="admin", href=admin_href, level=2,
items=SxExpr(nav),
)
def search_mobile_sx(ctx: dict) -> str: def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx call string.""" """Build mobile search input as sx call string."""
return sx_call("search-mobile", return sx_call("search-mobile",
@@ -154,43 +264,7 @@ def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
feature_image = post.get("feature_image") feature_image = post.get("feature_image")
label_sx = sx_call("post-label", feature_image=feature_image, title=title) label_sx = sx_call("post-label", feature_image=feature_image, title=title)
nav_sx = _post_nav_items_sx(ctx) or None
nav_parts: list[str] = []
page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
nav_parts.append(sx_call("page-cart-badge", href=cart_href, count=str(page_cart_count)))
container_nav = ctx.get("container_nav")
if container_nav:
nav_parts.append(
f'(div :id "entries-calendars-nav-wrapper"'
f' :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
f' {container_nav})'
)
# Admin cog
admin_nav = ctx.get("post_admin_nav")
if not admin_nav:
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
if has_admin and slug:
from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
base_cls = ("justify-center cursor-pointer flex flex-row"
" items-center gap-2 rounded bg-stone-200 text-black p-3")
admin_nav = (
f'(div :class "relative nav-group"'
f' (a :href "{admin_href}"'
f' :class "{base_cls} {sel_cls}"'
f' (i :class "fa fa-cog" :aria-hidden "true")))'
)
if admin_nav:
nav_parts.append(admin_nav)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
link_href = call_url(ctx, "blog_url", f"/{slug}/") link_href = call_url(ctx, "blog_url", f"/{slug}/")
return sx_call("menu-row-sx", return sx_call("menu-row-sx",
@@ -212,39 +286,7 @@ def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
label_parts.append(f'(span :class "text-white" "{escape(selected)}")') label_parts.append(f'(span :class "text-white" "{escape(selected)}")')
label_sx = "(<> " + " ".join(label_parts) + ")" label_sx = "(<> " + " ".join(label_parts) + ")"
# Nav items nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or None
select_colours = ctx.get("select_colours", "")
base_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded bg-stone-200 text-black p-3")
selected_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded !bg-stone-500 !text-white p-3")
nav_parts: list[str] = []
items = [
("events_url", f"/{slug}/admin/", "calendars"),
("market_url", f"/{slug}/admin/", "markets"),
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
("blog_url", f"/{slug}/admin/preview/", "preview"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
for url_key, path, label in items:
url_fn = ctx.get(url_key)
if not callable(url_fn):
continue
href = url_fn(path)
is_sel = label == selected
cls = selected_cls if is_sel else base_cls
aria = "true" if is_sel else None
nav_parts.append(
f'(div :class "relative nav-group"'
f' (a :href "{escape(href)}"'
+ (f' :aria-selected "true"' if aria else "")
+ f' :class "{cls} {escape(select_colours)}"'
+ f' "{escape(label)}"))'
)
nav_sx = "(<> " + " ".join(nav_parts) + ")" if nav_parts else None
if not admin_href: if not admin_href:
blog_fn = ctx.get("blog_url") blog_fn = ctx.get("blog_url")
@@ -301,7 +343,7 @@ def full_page_sx(ctx: dict, *, header_rows: str,
""" """
# Auto-generate mobile nav from context when no menu provided # Auto-generate mobile nav from context when no menu provided
if not menu: if not menu:
menu = mobile_nav_sx(ctx) menu = mobile_root_nav_sx(ctx)
body_sx = sx_call("app-body", body_sx = sx_call("app-body",
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None, header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
filter=SxExpr(filter) if filter else None, filter=SxExpr(filter) if filter else None,

View File

@@ -16,6 +16,8 @@ from typing import Any, Callable, Awaitable
from .helpers import ( from .helpers import (
root_header_sx, post_header_sx, post_admin_header_sx, root_header_sx, post_header_sx, post_admin_header_sx,
oob_header_sx, header_child_sx, oob_header_sx, header_child_sx,
mobile_menu_sx, mobile_root_nav_sx,
post_mobile_nav_sx, post_admin_mobile_nav_sx,
) )
@@ -26,17 +28,19 @@ from .helpers import (
class Layout: class Layout:
"""A named layout that generates header rows for full and OOB rendering.""" """A named layout that generates header rows for full and OOB rendering."""
__slots__ = ("name", "_full_fn", "_oob_fn") __slots__ = ("name", "_full_fn", "_oob_fn", "_mobile_fn")
def __init__( def __init__(
self, self,
name: str, name: str,
full_fn: Callable[..., str | Awaitable[str]], full_fn: Callable[..., str | Awaitable[str]],
oob_fn: Callable[..., str | Awaitable[str]], oob_fn: Callable[..., str | Awaitable[str]],
mobile_fn: Callable[..., str | Awaitable[str]] | None = None,
): ):
self.name = name self.name = name
self._full_fn = full_fn self._full_fn = full_fn
self._oob_fn = oob_fn self._oob_fn = oob_fn
self._mobile_fn = mobile_fn
async def full_headers(self, ctx: dict, **kwargs: Any) -> str: async def full_headers(self, ctx: dict, **kwargs: Any) -> str:
result = self._full_fn(ctx, **kwargs) result = self._full_fn(ctx, **kwargs)
@@ -50,6 +54,14 @@ class Layout:
result = await result result = await result
return result return result
async def mobile_menu(self, ctx: dict, **kwargs: Any) -> str:
if self._mobile_fn is None:
return ""
result = self._mobile_fn(ctx, **kwargs)
if hasattr(result, "__await__"):
result = await result
return result
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Layout:{self.name}>" return f"<Layout:{self.name}>"
@@ -113,9 +125,27 @@ def _post_admin_oob(ctx: dict, **kw: Any) -> str:
return "(<> " + post_hdr + " " + admin_oob + ")" return "(<> " + post_hdr + " " + admin_oob + ")"
register_layout(Layout("root", _root_full, _root_oob)) def _root_mobile(ctx: dict, **kw: Any) -> str:
register_layout(Layout("post", _post_full, _post_oob)) return mobile_root_nav_sx(ctx)
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob))
def _post_mobile(ctx: dict, **kw: Any) -> str:
return mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
def _post_admin_mobile(ctx: dict, **kw: Any) -> str:
slug = ctx.get("post", {}).get("slug", "")
selected = kw.get("selected", "")
return mobile_menu_sx(
post_admin_mobile_nav_sx(ctx, slug, selected),
post_mobile_nav_sx(ctx),
mobile_root_nav_sx(ctx),
)
register_layout(Layout("root", _root_full, _root_oob, _root_mobile))
register_layout(Layout("post", _post_full, _post_oob, _post_mobile))
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob, _post_admin_mobile))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -127,13 +157,15 @@ _CUSTOM_LAYOUTS: dict[str, tuple] = {} # name → (full_fn, oob_fn)
def register_custom_layout(name: str, def register_custom_layout(name: str,
full_fn: Callable[..., str | Awaitable[str]], full_fn: Callable[..., str | Awaitable[str]],
oob_fn: Callable[..., str | Awaitable[str]]) -> None: oob_fn: Callable[..., str | Awaitable[str]],
mobile_fn: Callable[..., str | Awaitable[str]] | None = None) -> None:
"""Register a custom layout function. """Register a custom layout function.
Used by services with non-standard header patterns:: Used by services with non-standard header patterns::
register_custom_layout("sx-section", register_custom_layout("sx-section",
full_fn=my_full_headers, full_fn=my_full_headers,
oob_fn=my_oob_headers) oob_fn=my_oob_headers,
mobile_fn=my_mobile_menu)
""" """
register_layout(Layout(name, full_fn, oob_fn)) register_layout(Layout(name, full_fn, oob_fn, mobile_fn))

View File

@@ -219,7 +219,7 @@ async def execute_page(
if page_def.menu_expr is not None: if page_def.menu_expr is not None:
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx) menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx)
# Resolve layout → header rows # Resolve layout → header rows + mobile menu fallback
tctx = await get_template_context() tctx = await get_template_context()
header_rows = "" header_rows = ""
oob_headers = "" oob_headers = ""
@@ -261,6 +261,8 @@ async def execute_page(
if layout is not None: if layout is not None:
header_rows = await layout.full_headers(tctx, **layout_kwargs) header_rows = await layout.full_headers(tctx, **layout_kwargs)
oob_headers = await layout.oob_headers(tctx, **layout_kwargs) oob_headers = await layout.oob_headers(tctx, **layout_kwargs)
if not menu_sx:
menu_sx = await layout.mobile_menu(tctx, **layout_kwargs)
# Branch on request type # Branch on request type
is_htmx = is_htmx_request() is_htmx = is_htmx_request()
@@ -288,17 +290,22 @@ async def execute_page(
# Blueprint mounting # Blueprint mounting
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def mount_pages(bp: Any, service_name: str) -> None: def mount_pages(bp: Any, service_name: str,
"""Mount all registered PageDef routes onto a Quart Blueprint. names: set[str] | list[str] | None = None) -> None:
"""Mount registered PageDef routes onto a Quart Blueprint.
For each PageDef, adds a GET route with appropriate auth/cache For each PageDef, adds a GET route with appropriate auth/cache
decorators. Coexists with existing Python routes on the same blueprint. decorators. Coexists with existing Python routes on the same blueprint.
If *names* is given, only mount pages whose name is in the set.
""" """
from quart import make_response from quart import make_response
pages = get_all_pages(service_name) pages = get_all_pages(service_name)
for page_def in pages.values(): for page_def in pages.values():
if names is not None and page_def.name not in names:
continue
_mount_one_page(bp, service_name, page_def) _mount_one_page(bp, service_name, page_def)
@@ -347,6 +354,9 @@ def _apply_auth(fn: Any, auth: str | list) -> Any:
if auth == "admin": if auth == "admin":
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
return require_admin(fn) return require_admin(fn)
if auth == "post_author":
from shared.browser.app.authz import require_post_author
return require_post_author(fn)
if isinstance(auth, list) and auth and auth[0] == "rights": if isinstance(auth, list) and auth and auth[0] == "rights":
from shared.browser.app.authz import require_rights from shared.browser.app.authz import require_rights
return require_rights(*auth[1:])(fn) return require_rights(*auth[1:])(fn)

Some files were not shown because too many files have changed in this diff Show More