Migrate all apps to defpage declarative page routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m41s
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:
@@ -72,9 +72,19 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# Setup defpage routes
|
||||
import sx.sx_components # noqa: F811 — ensure components loaded
|
||||
from sxc.pages import setup_account_pages
|
||||
setup_account_pages()
|
||||
|
||||
# --- blueprints ---
|
||||
app.register_blueprint(register_auth_bp())
|
||||
app.register_blueprint(register_account_bp())
|
||||
|
||||
account_bp = register_account_bp()
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(account_bp, "account")
|
||||
app.register_blueprint(account_bp)
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
from bp.actions.routes import register as register_actions
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Account pages blueprint.
|
||||
|
||||
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
|
||||
Mounted at root /.
|
||||
Mounted at root /. GET page handlers replaced by defpage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
make_response,
|
||||
redirect,
|
||||
g,
|
||||
)
|
||||
@@ -20,85 +19,62 @@ from shared.infrastructure.urls import login_url
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
oob = {
|
||||
"oob_extends": "oob_elements.html",
|
||||
"extends": "_types/root/_index.html",
|
||||
"parent_id": "root-header-child",
|
||||
"child_id": "auth-header-child",
|
||||
"header": "_types/auth/header/_header.html",
|
||||
"parent_header": "_types/root/header/_header.html",
|
||||
"nav": "_types/auth/_nav.html",
|
||||
"main": "_types/auth/_main_panel.html",
|
||||
}
|
||||
|
||||
|
||||
def register(url_prefix="/"):
|
||||
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
|
||||
|
||||
@account_bp.context_processor
|
||||
async def context():
|
||||
@account_bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Fetch account_nav fragments and load data for defpage routes."""
|
||||
# Fetch account nav items for layout (was in context_processor)
|
||||
events_nav, cart_nav, artdag_nav = await fetch_fragments([
|
||||
("events", "account-nav-item", {}),
|
||||
("cart", "account-nav-item", {}),
|
||||
("artdag", "nav-item", {}),
|
||||
], required=False)
|
||||
return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav}
|
||||
g.account_nav = events_nav + cart_nav + artdag_nav
|
||||
|
||||
@account_bp.get("/")
|
||||
async def account():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_account_page, render_account_oob
|
||||
if request.method != "GET":
|
||||
return
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/"))
|
||||
endpoint = request.endpoint or ""
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_account_page(ctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_account_oob(ctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@account_bp.get("/newsletters/")
|
||||
async def newsletters():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
if not g.get("user"):
|
||||
return redirect(login_url("/newsletters/"))
|
||||
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
sub_result = await g.s.execute(
|
||||
select(UserNewsletter).where(
|
||||
UserNewsletter.user_id == g.user.id,
|
||||
# Newsletters page — load newsletter data
|
||||
if endpoint.endswith("defpage_newsletters"):
|
||||
result = await g.s.execute(
|
||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
all_newsletters = result.scalars().all()
|
||||
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": nl,
|
||||
"un": un,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
sub_result = await g.s.execute(
|
||||
select(UserNewsletter).where(
|
||||
UserNewsletter.user_id == g.user.id,
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_newsletters_page, render_newsletters_oob
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": nl,
|
||||
"un": un,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
g.newsletters_data = newsletter_list
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_newsletters_page(ctx, newsletter_list)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_newsletters_oob(ctx, newsletter_list)
|
||||
return sx_response(sx_src)
|
||||
# Fragment page — load fragment from events service
|
||||
elif endpoint.endswith("defpage_fragment_page"):
|
||||
slug = request.view_args.get("slug")
|
||||
if slug and g.get("user"):
|
||||
fragment_html = await fetch_fragment(
|
||||
"events", "account-page",
|
||||
params={"slug": slug, "user_id": str(g.user.id)},
|
||||
)
|
||||
if not fragment_html:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
g.fragment_page_data = fragment_html
|
||||
|
||||
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
||||
async def toggle_newsletter(newsletter_id: int):
|
||||
@@ -128,31 +104,4 @@ def register(url_prefix="/"):
|
||||
from sx.sx_components import render_newsletter_toggle
|
||||
return sx_response(render_newsletter_toggle(un))
|
||||
|
||||
# Catch-all for fragment-provided pages — must be last
|
||||
@account_bp.get("/<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
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
call_url, sx_call, SxExpr,
|
||||
root_header_sx, full_page_sx, header_child_sx, oob_page_sx,
|
||||
root_header_sx, full_page_sx,
|
||||
)
|
||||
|
||||
# Load account-specific .sx components + handlers at import time
|
||||
@@ -238,88 +238,8 @@ def _device_approved_content() -> str:
|
||||
# Public API: Account dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_account_page(ctx: dict) -> str:
|
||||
"""Full page: account dashboard."""
|
||||
main = _account_main_panel_sx(ctx)
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
async def render_account_oob(ctx: dict) -> str:
|
||||
"""OOB response for account dashboard."""
|
||||
main = _account_main_panel_sx(ctx)
|
||||
|
||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Newsletters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Full page: newsletters."""
|
||||
main = _newsletters_panel_sx(ctx, newsletter_list)
|
||||
|
||||
hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
||||
"""OOB response for newsletters."""
|
||||
main = _newsletters_panel_sx(ctx, newsletter_list)
|
||||
|
||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
return oob_page_sx(oobs=oobs,
|
||||
content=main,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Fragment pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_fragment_page(ctx: dict, page_fragment: str) -> str:
|
||||
"""Full page: fragment-provided content.
|
||||
|
||||
*page_fragment* may be sx source (from text/sx fragments wrapped in
|
||||
SxExpr) or HTML (from text/html fragments). Sx source is embedded
|
||||
directly; HTML is wrapped in ``~rich-text``.
|
||||
"""
|
||||
from shared.sx.parser import SxExpr
|
||||
hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||
|
||||
content = _fragment_content(page_fragment)
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
content=content,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
async def render_fragment_oob(ctx: dict, page_fragment: str) -> str:
|
||||
"""OOB response for fragment pages."""
|
||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
content = _fragment_content(page_fragment)
|
||||
return oob_page_sx(oobs=oobs,
|
||||
content=content,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
def _fragment_content(frag: object) -> str:
|
||||
|
||||
0
account/sxc/__init__.py
Normal file
0
account/sxc/__init__.py
Normal file
105
account/sxc/pages/__init__.py
Normal file
105
account/sxc/pages/__init__.py
Normal 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)
|
||||
31
account/sxc/pages/account.sx
Normal file
31
account/sxc/pages/account.sx
Normal 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))
|
||||
@@ -20,6 +20,7 @@ from bp import (
|
||||
register_data,
|
||||
register_actions,
|
||||
)
|
||||
from sxc.pages import setup_blog_pages
|
||||
|
||||
|
||||
async def blog_context() -> dict:
|
||||
@@ -80,6 +81,8 @@ async def blog_context() -> dict:
|
||||
def create_app() -> "Quart":
|
||||
from services import register_domain_services
|
||||
|
||||
setup_blog_pages()
|
||||
|
||||
app = create_base_app(
|
||||
"blog",
|
||||
context_fn=blog_context,
|
||||
|
||||
@@ -27,33 +27,22 @@ def register(url_prefix):
|
||||
"base_title": f"{config()['title']} settings",
|
||||
}
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def home():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_settings_page, render_settings_oob
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_settings_home" in ep:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _settings_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
g.settings_content = _settings_main_panel_sx(tctx)
|
||||
elif "defpage_cache_page" in ep:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cache_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
g.cache_content = _cache_main_panel_sx(tctx)
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_settings_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_settings_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/cache/")
|
||||
@require_admin
|
||||
async def cache():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_cache_page, render_cache_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_cache_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_cache_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
|
||||
|
||||
@bp.post("/cache_clear/")
|
||||
@require_admin
|
||||
@@ -65,7 +54,7 @@ def register(url_prefix):
|
||||
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
|
||||
return sx_response(html)
|
||||
|
||||
return redirect(url_for("settings.cache"))
|
||||
return redirect(url_for("settings.defpage_cache_page"))
|
||||
return bp
|
||||
|
||||
|
||||
|
||||
@@ -46,27 +46,52 @@ async def _unassigned_tags(session):
|
||||
def register():
|
||||
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def index():
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(g.s)
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_tag_groups_page" in ep:
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(g.s)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tag_groups_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({"groups": groups, "unassigned_tags": unassigned})
|
||||
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
|
||||
elif "defpage_tag_group_edit" in ep:
|
||||
tag_id = (request.view_args or {}).get("id")
|
||||
tg = await g.s.get(TagGroup, tag_id)
|
||||
if not tg:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
assigned_rows = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
|
||||
)).scalars()
|
||||
)
|
||||
all_tags = list(
|
||||
(await g.s.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tag_groups_edit_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({
|
||||
"group": tg,
|
||||
"all_tags": all_tags,
|
||||
"assigned_tag_ids": set(assigned_rows),
|
||||
})
|
||||
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
|
||||
|
||||
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_tag_groups_page, render_tag_groups_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
return await make_response(await render_tag_groups_page(tctx))
|
||||
else:
|
||||
return sx_response(await render_tag_groups_oob(tctx))
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
@@ -74,7 +99,7 @@ def register():
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
if not name:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
|
||||
slug = _slugify(name)
|
||||
feature_image = (form.get("feature_image") or "").strip() or None
|
||||
@@ -90,55 +115,14 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
|
||||
@bp.get("/<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))
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
|
||||
@bp.post("/<int:id>/")
|
||||
@require_admin
|
||||
async def save(id: int):
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
@@ -169,7 +153,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id))
|
||||
|
||||
@bp.post("/<int:id>/delete/")
|
||||
@require_admin
|
||||
@@ -179,6 +163,6 @@ def register():
|
||||
await g.s.delete(tg)
|
||||
await g.s.flush()
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -51,10 +51,19 @@ def register(url_prefix, title):
|
||||
pass
|
||||
|
||||
@blogs_bp.before_request
|
||||
def route():
|
||||
async def route():
|
||||
g.makeqs_factory = makeqs_factory
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_new_post" in ep:
|
||||
from sx.sx_components import render_editor_panel
|
||||
g.editor_content = render_editor_panel()
|
||||
elif "defpage_new_page" in ep:
|
||||
from sx.sx_components import render_editor_panel
|
||||
g.editor_page_content = render_editor_panel(is_page=True)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
|
||||
|
||||
|
||||
@blogs_bp.context_processor
|
||||
async def inject_root():
|
||||
return {
|
||||
@@ -215,21 +224,6 @@ def register(url_prefix, title):
|
||||
sx_src = await render_blog_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.get("/new/")
|
||||
@require_admin
|
||||
async def new_post():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel()
|
||||
if not is_htmx_request():
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_new_post_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.post("/new/")
|
||||
@require_admin
|
||||
async def new_post_save():
|
||||
@@ -283,25 +277,9 @@ def register(url_prefix, title):
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the edit page
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=post.slug)))
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)))
|
||||
|
||||
|
||||
@blogs_bp.get("/new-page/")
|
||||
@require_admin
|
||||
async def new_page():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["editor_html"] = render_editor_panel(is_page=True)
|
||||
tctx["is_page"] = True
|
||||
if not is_htmx_request():
|
||||
html = await render_new_post_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_new_post_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@blogs_bp.post("/new-page/")
|
||||
@require_admin
|
||||
async def new_page_save():
|
||||
@@ -357,7 +335,7 @@ def register(url_prefix, title):
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the page admin
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=page.slug)))
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug)))
|
||||
|
||||
|
||||
@blogs_bp.get("/drafts/")
|
||||
|
||||
@@ -23,24 +23,19 @@ def register():
|
||||
from sx.sx_components import render_menu_items_nav_oob
|
||||
return render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def list_menu_items():
|
||||
"""List all menu items"""
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
return
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_menu_items_page, render_menu_items_oob
|
||||
|
||||
from sx.sx_components import _menu_items_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
if not is_htmx_request():
|
||||
html = await render_menu_items_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_menu_items_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
g.menu_items_content = _menu_items_main_panel_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["menu-items-page"])
|
||||
|
||||
@bp.get("/new/")
|
||||
@require_admin
|
||||
|
||||
@@ -55,51 +55,154 @@ def _post_to_edit_dict(post) -> dict:
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(slug: str):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_post_admin" in ep:
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
sumup_merchant_code = ""
|
||||
sumup_checkout_prefix = ""
|
||||
if post.get("is_page"):
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
sumup_merchant_code = pc.sumup_merchant_code or ""
|
||||
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_admin_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx.update({
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
"sumup_merchant_code": sumup_merchant_code,
|
||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||
})
|
||||
g.post_admin_content = _post_admin_main_panel_sx(tctx)
|
||||
|
||||
# Load features for page admin (page_configs now lives in db_blog)
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
sumup_merchant_code = ""
|
||||
sumup_checkout_prefix = ""
|
||||
if post.get("is_page"):
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
sumup_merchant_code = pc.sumup_merchant_code or ""
|
||||
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
||||
elif "defpage_post_data" in ep:
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_data_content_sx
|
||||
tctx = await get_template_context()
|
||||
g.post_data_content = _post_data_content_sx(tctx)
|
||||
|
||||
ctx = {
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
"sumup_merchant_code": sumup_merchant_code,
|
||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||
}
|
||||
elif "defpage_post_preview" in ep:
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
preview_ctx = {}
|
||||
sx_content = getattr(post, "sx_content", None) or ""
|
||||
if sx_content:
|
||||
from shared.sx.prettify import sx_to_pretty_sx
|
||||
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
|
||||
lexical_raw = getattr(post, "lexical", None) or ""
|
||||
if lexical_raw:
|
||||
from shared.sx.prettify import json_to_pretty_sx
|
||||
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
|
||||
if sx_content:
|
||||
from shared.sx.parser import parse as sx_parse
|
||||
from shared.sx.html import render as sx_html_render
|
||||
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
||||
try:
|
||||
parsed = sx_parse(sx_content)
|
||||
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
|
||||
except Exception:
|
||||
preview_ctx["sx_rendered"] = "<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
|
||||
from sx.sx_components import render_post_admin_page, render_post_admin_oob
|
||||
elif "defpage_post_entries" in ep:
|
||||
from sqlalchemy import select
|
||||
from shared.models.calendars import Calendar
|
||||
from ..services.entry_associations import get_post_entry_ids
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
result = await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
all_calendars = result.scalars().all()
|
||||
for calendar in all_calendars:
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_entries_content_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["all_calendars"] = all_calendars
|
||||
tctx["associated_entry_ids"] = associated_entry_ids
|
||||
g.post_entries_content = _post_entries_content_sx(tctx)
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
html = await render_post_admin_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_admin_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
elif "defpage_post_settings" in ep:
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||
save_success = request.args.get("saved") == "1"
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_settings_content_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["ghost_post"] = ghost_post
|
||||
tctx["save_success"] = save_success
|
||||
g.post_settings_content = _post_settings_content_sx(tctx)
|
||||
|
||||
elif "defpage_post_edit" in ep:
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||
save_success = request.args.get("saved") == "1"
|
||||
save_error = request.args.get("error", "")
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
from types import SimpleNamespace
|
||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_edit_content_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["ghost_post"] = ghost_post
|
||||
tctx["save_success"] = save_success
|
||||
tctx["save_error"] = save_error
|
||||
tctx["newsletters"] = newsletters
|
||||
g.post_edit_content = _post_edit_content_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=[
|
||||
"post-admin", "post-data", "post-preview",
|
||||
"post-entries", "post-settings", "post-edit",
|
||||
])
|
||||
|
||||
@bp.put("/features/")
|
||||
@require_admin
|
||||
@@ -184,77 +287,6 @@ def register():
|
||||
)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.get("/data/")
|
||||
@require_admin
|
||||
async def data(slug: str):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_data_page, render_post_data_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_post_data_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_data_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/preview/")
|
||||
@require_admin
|
||||
async def preview(slug: str):
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_preview_page, render_post_preview_oob
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Build the 4 preview views
|
||||
preview_ctx = {}
|
||||
|
||||
# 1. Prettified sx source
|
||||
sx_content = getattr(post, "sx_content", None) or ""
|
||||
if sx_content:
|
||||
from shared.sx.prettify import sx_to_pretty_sx
|
||||
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
|
||||
|
||||
# 2. Prettified lexical JSON
|
||||
lexical_raw = getattr(post, "lexical", None) or ""
|
||||
if lexical_raw:
|
||||
from shared.sx.prettify import json_to_pretty_sx
|
||||
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
|
||||
|
||||
# 3. SX rendered preview
|
||||
if sx_content:
|
||||
from shared.sx.parser import parse as sx_parse
|
||||
from shared.sx.html import render as sx_html_render
|
||||
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
||||
try:
|
||||
parsed = sx_parse(sx_content)
|
||||
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
|
||||
except Exception:
|
||||
preview_ctx["sx_rendered"] = "<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>/")
|
||||
@require_admin
|
||||
async def calendar_view(slug: str, calendar_id: int):
|
||||
@@ -330,40 +362,6 @@ def register():
|
||||
)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.get("/entries/")
|
||||
@require_admin
|
||||
async def entries(slug: str):
|
||||
from ..services.entry_associations import get_post_entry_ids
|
||||
from shared.models.calendars import Calendar
|
||||
from sqlalchemy import select
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
|
||||
# Load ALL calendars (not just this post's calendars)
|
||||
result = await g.s.execute(
|
||||
select(Calendar)
|
||||
.where(Calendar.deleted_at.is_(None))
|
||||
.order_by(Calendar.name.asc())
|
||||
)
|
||||
all_calendars = result.scalars().all()
|
||||
|
||||
# Load entries and post for each calendar
|
||||
for calendar in all_calendars:
|
||||
await g.s.refresh(calendar, ["entries", "post"])
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_entries_page, render_post_entries_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["all_calendars"] = all_calendars
|
||||
tctx["associated_entry_ids"] = associated_entry_ids
|
||||
if not is_htmx_request():
|
||||
html = await render_post_entries_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_entries_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.post("/entries/<int:entry_id>/toggle/")
|
||||
@require_admin
|
||||
async def toggle_entry(slug: str, entry_id: int):
|
||||
@@ -416,36 +414,6 @@ def register():
|
||||
|
||||
return sx_response(admin_list + nav_entries_html)
|
||||
|
||||
@bp.get("/settings/")
|
||||
@require_post_author
|
||||
async def settings(slug: str):
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||
save_success = request.args.get("saved") == "1"
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_settings_page, render_post_settings_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["ghost_post"] = ghost_post
|
||||
tctx["save_success"] = save_success
|
||||
if not is_htmx_request():
|
||||
html = await render_post_settings_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_settings_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.post("/settings/")
|
||||
@require_post_author
|
||||
async def settings_save(slug: str):
|
||||
@@ -500,7 +468,7 @@ def register():
|
||||
except OptimisticLockError:
|
||||
from urllib.parse import quote
|
||||
return redirect(
|
||||
host_url(url_for("blog.post.admin.settings", slug=slug))
|
||||
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
@@ -511,46 +479,7 @@ def register():
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect using the (possibly new) slug
|
||||
return redirect(host_url(url_for("blog.post.admin.settings", slug=post.slug)) + "?saved=1")
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_post_author
|
||||
async def edit(slug: str):
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post)
|
||||
.where(Post.id == post_id)
|
||||
.options(selectinload(Post.tags))
|
||||
)).scalar_one_or_none()
|
||||
|
||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||
save_success = request.args.get("saved") == "1"
|
||||
save_error = request.args.get("error", "")
|
||||
|
||||
# Newsletters live in db_account — fetch via HTTP
|
||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||
from types import SimpleNamespace
|
||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_edit_page, render_post_edit_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["ghost_post"] = ghost_post
|
||||
tctx["save_success"] = save_success
|
||||
tctx["save_error"] = save_error
|
||||
tctx["newsletters"] = newsletters
|
||||
if not is_htmx_request():
|
||||
html = await render_post_edit_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_edit_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1")
|
||||
|
||||
@bp.post("/edit/")
|
||||
@require_post_author
|
||||
@@ -575,11 +504,11 @@ def register():
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
|
||||
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
|
||||
|
||||
# Publish workflow
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
@@ -615,7 +544,7 @@ def register():
|
||||
)
|
||||
except OptimisticLockError:
|
||||
return redirect(
|
||||
host_url(url_for("blog.post.admin.edit", slug=slug))
|
||||
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
@@ -631,7 +560,7 @@ def register():
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect to GET (PRG pattern) — use post.slug in case it changed
|
||||
redirect_url = host_url(url_for("blog.post.admin.edit", slug=post.slug)) + "?saved=1"
|
||||
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1"
|
||||
if publish_requested_msg:
|
||||
redirect_url += "&publish_requested=1"
|
||||
return redirect(redirect_url)
|
||||
|
||||
@@ -32,25 +32,21 @@ async def _visible_snippets(session):
|
||||
def register():
|
||||
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
|
||||
|
||||
@bp.get("/")
|
||||
@require_login
|
||||
async def list_snippets():
|
||||
"""List snippets visible to the current user."""
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
return
|
||||
snippets = await _visible_snippets(g.s)
|
||||
is_admin = g.rights.get("admin")
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_snippets_page, render_snippets_oob
|
||||
|
||||
from sx.sx_components import _snippets_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = snippets
|
||||
tctx["is_admin"] = is_admin
|
||||
if not is_htmx_request():
|
||||
html = await render_snippets_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_snippets_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
g.snippets_content = _snippets_main_panel_sx(tctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "blog", names=["snippets-page"])
|
||||
|
||||
@bp.delete("/<int:snippet_id>/")
|
||||
@require_login
|
||||
|
||||
@@ -26,6 +26,10 @@ from shared.sx.helpers import (
|
||||
search_mobile_sx,
|
||||
search_desktop_sx,
|
||||
full_page_sx,
|
||||
mobile_menu_sx,
|
||||
mobile_root_nav_sx,
|
||||
post_mobile_nav_sx,
|
||||
post_admin_mobile_nav_sx,
|
||||
)
|
||||
|
||||
# Load blog service .sx component definitions + handler definitions
|
||||
@@ -76,6 +80,15 @@ def _post_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -
|
||||
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
def _post_admin_mobile_menu(ctx: dict, selected: str = "") -> str:
|
||||
"""Full mobile menu for any post admin page (admin + post + root)."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return mobile_menu_sx(
|
||||
post_admin_mobile_nav_sx(ctx, slug, selected),
|
||||
post_mobile_nav_sx(ctx),
|
||||
mobile_root_nav_sx(ctx),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings header (root-header-child -> root-settings-header-child)
|
||||
@@ -85,7 +98,7 @@ def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Settings header row with admin icon and nav links (sx)."""
|
||||
from quart import url_for as qurl
|
||||
|
||||
settings_href = qurl("settings.home")
|
||||
settings_href = qurl("settings.defpage_settings_home")
|
||||
label_sx = sx_call("blog-admin-label")
|
||||
nav_sx = _settings_nav_sx(ctx)
|
||||
|
||||
@@ -107,10 +120,10 @@ def _settings_nav_sx(ctx: dict) -> str:
|
||||
parts = []
|
||||
|
||||
for endpoint, icon, label in [
|
||||
("menu_items.list_menu_items", "bars", "Menu Items"),
|
||||
("snippets.list_snippets", "puzzle-piece", "Snippets"),
|
||||
("blog.tag_groups_admin.index", "tags", "Tag Groups"),
|
||||
("settings.cache", "refresh", "Cache"),
|
||||
("menu_items.defpage_menu_items_page", "bars", "Menu Items"),
|
||||
("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"),
|
||||
("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"),
|
||||
("settings.defpage_cache_page", "refresh", "Cache"),
|
||||
]:
|
||||
href = qurl(endpoint)
|
||||
parts.append(sx_call("nav-link",
|
||||
@@ -679,7 +692,7 @@ def _post_main_panel_sx(ctx: dict) -> str:
|
||||
if post.get("status") == "draft":
|
||||
edit_sx = ""
|
||||
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
|
||||
edit_href = qurl("blog.post.admin.edit", slug=slug)
|
||||
edit_href = qurl("blog.post.admin.defpage_post_edit", slug=slug)
|
||||
edit_sx = sx_call("blog-detail-edit-link",
|
||||
href=edit_href, hx_select=hx_select,
|
||||
)
|
||||
@@ -951,7 +964,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
|
||||
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
|
||||
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
|
||||
|
||||
edit_href = qurl("blog.tag_groups_admin.edit", id=g_id)
|
||||
edit_href = qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id)
|
||||
|
||||
if g_fi:
|
||||
icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name)
|
||||
@@ -1053,7 +1066,7 @@ async def render_home_page(ctx: dict) -> str:
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
content = _home_main_panel_sx(ctx)
|
||||
meta = _post_meta_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
|
||||
@@ -1088,9 +1101,8 @@ async def render_blog_oob(ctx: dict) -> str:
|
||||
content = _blog_main_panel_sx(ctx)
|
||||
aside = _blog_aside_sx(ctx)
|
||||
filter_sx = _blog_filter_sx(ctx)
|
||||
nav = ctx.get("nav_sx", "") or ""
|
||||
return oob_page_sx(oobs=header_oob, content=content, aside=aside,
|
||||
filter=filter_sx, menu=nav)
|
||||
filter=filter_sx)
|
||||
|
||||
|
||||
async def render_blog_cards(ctx: dict) -> str:
|
||||
@@ -1304,15 +1316,6 @@ async def render_new_post_page(ctx: dict) -> str:
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_new_post_oob(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
blog_hdr = _blog_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
content = ctx.get("editor_html", "")
|
||||
return oob_page_sx(oobs=header_oob, content=content)
|
||||
|
||||
|
||||
# ---- Post detail ----
|
||||
|
||||
async def render_post_page(ctx: dict) -> str:
|
||||
@@ -1321,7 +1324,7 @@ async def render_post_page(ctx: dict) -> str:
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
content = _post_main_panel_sx(ctx)
|
||||
meta = _post_meta_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
|
||||
@@ -1332,35 +1335,14 @@ async def render_post_oob(ctx: dict) -> str:
|
||||
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
|
||||
content = _post_main_panel_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
|
||||
oobs = post_oob
|
||||
return oob_page_sx(oobs=oobs, content=content, menu=menu)
|
||||
|
||||
|
||||
# ---- Post admin ----
|
||||
|
||||
async def render_post_admin_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx)
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _post_admin_main_panel_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
menu=menu)
|
||||
|
||||
|
||||
async def render_post_admin_oob(ctx: dict) -> str:
|
||||
post_hdr_oob = _post_header_sx(ctx, oob=True)
|
||||
admin_oob = _oob_header_sx("post-header-child", "post-admin-header-child",
|
||||
_post_admin_header_sx(ctx))
|
||||
content = _post_admin_main_panel_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
oobs = "(<> " + post_hdr_oob + " " + admin_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content, menu=menu)
|
||||
|
||||
|
||||
# ---- Post data ----
|
||||
# ===========================================================================
|
||||
|
||||
def _post_data_content_sx(ctx: dict) -> str:
|
||||
"""Build post data inspector panel natively (replaces _types/post_data/_main_panel.html)."""
|
||||
@@ -1478,22 +1460,7 @@ def _post_data_content_sx(ctx: dict) -> str:
|
||||
return _raw_html_sx(html)
|
||||
|
||||
|
||||
async def render_post_data_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="data")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _post_data_content_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_post_data_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="data")
|
||||
content = _post_data_content_sx(ctx)
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
|
||||
|
||||
# ---- Post preview ----
|
||||
# ===========================================================================
|
||||
|
||||
def _preview_main_panel_sx(ctx: dict) -> str:
|
||||
"""Build the preview panel with 4 expandable sections."""
|
||||
@@ -1540,22 +1507,7 @@ def _preview_main_panel_sx(ctx: dict) -> str:
|
||||
return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))
|
||||
|
||||
|
||||
async def render_post_preview_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="preview")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _preview_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_post_preview_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="preview")
|
||||
content = _preview_main_panel_sx(ctx)
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
|
||||
|
||||
# ---- Post entries ----
|
||||
# ===========================================================================
|
||||
|
||||
def _post_entries_content_sx(ctx: dict) -> str:
|
||||
"""Build post entries panel natively (replaces _types/post_entries/_main_panel.html)."""
|
||||
@@ -1613,21 +1565,6 @@ def _post_entries_content_sx(ctx: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
async def render_post_entries_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="entries")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _post_entries_content_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_post_entries_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="entries")
|
||||
content = _post_entries_content_sx(ctx)
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
|
||||
|
||||
# ---- Calendar view (for entries browser) ----
|
||||
|
||||
def render_calendar_view(
|
||||
@@ -2045,22 +1982,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
||||
return _raw_html_sx("".join(parts))
|
||||
|
||||
|
||||
async def render_post_edit_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="edit")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _post_edit_content_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_post_edit_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="edit")
|
||||
content = _post_edit_content_sx(ctx)
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
|
||||
|
||||
# ---- Post settings ----
|
||||
# ===========================================================================
|
||||
|
||||
def _post_settings_content_sx(ctx: dict) -> str:
|
||||
"""Build settings form natively (replaces _types/post_settings/_main_panel.html)."""
|
||||
@@ -2195,189 +2117,17 @@ def _post_settings_content_sx(ctx: dict) -> str:
|
||||
return _raw_html_sx(html)
|
||||
|
||||
|
||||
async def render_post_settings_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="settings")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _post_settings_content_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
async def render_post_settings_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="settings")
|
||||
content = _post_settings_content_sx(ctx)
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
# ---- Settings home ----
|
||||
|
||||
async def render_settings_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
content = _settings_main_panel_sx(ctx)
|
||||
menu = _settings_nav_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
menu=menu)
|
||||
|
||||
|
||||
async def render_settings_oob(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
header_oob = _oob_header_sx("root-header-child", "root-settings-header-child", rows)
|
||||
content = _settings_main_panel_sx(ctx)
|
||||
menu = _settings_nav_sx(ctx)
|
||||
return oob_page_sx(oobs=header_oob, content=content, menu=menu)
|
||||
|
||||
|
||||
# ---- Cache ----
|
||||
|
||||
async def render_cache_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
cache_hdr = _sub_settings_header_sx(
|
||||
"cache-row", "cache-header-child",
|
||||
qurl("settings.cache"), "refresh", "Cache", ctx,
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + cache_hdr + ")"
|
||||
content = _cache_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_cache_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
cache_hdr = _sub_settings_header_sx(
|
||||
"cache-row", "cache-header-child",
|
||||
qurl("settings.cache"), "refresh", "Cache", ctx,
|
||||
)
|
||||
cache_oob = _oob_header_sx("root-settings-header-child", "cache-header-child",
|
||||
cache_hdr)
|
||||
content = _cache_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + cache_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
# ---- Snippets ----
|
||||
|
||||
async def render_snippets_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
snippets_hdr = _sub_settings_header_sx(
|
||||
"snippets-row", "snippets-header-child",
|
||||
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + snippets_hdr + ")"
|
||||
content = _snippets_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_snippets_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
snippets_hdr = _sub_settings_header_sx(
|
||||
"snippets-row", "snippets-header-child",
|
||||
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
|
||||
)
|
||||
snippets_oob = _oob_header_sx("root-settings-header-child", "snippets-header-child",
|
||||
snippets_hdr)
|
||||
content = _snippets_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + snippets_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
# ---- Menu items ----
|
||||
|
||||
async def render_menu_items_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
mi_hdr = _sub_settings_header_sx(
|
||||
"menu_items-row", "menu_items-header-child",
|
||||
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + mi_hdr + ")"
|
||||
content = _menu_items_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_menu_items_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
mi_hdr = _sub_settings_header_sx(
|
||||
"menu_items-row", "menu_items-header-child",
|
||||
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
|
||||
)
|
||||
mi_oob = _oob_header_sx("root-settings-header-child", "menu_items-header-child",
|
||||
mi_hdr)
|
||||
content = _menu_items_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + mi_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
# ---- Tag groups ----
|
||||
|
||||
async def render_tag_groups_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
|
||||
content = _tag_groups_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_tag_groups_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
|
||||
)
|
||||
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
|
||||
tg_hdr)
|
||||
content = _tag_groups_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
# ---- Tag group edit ----
|
||||
|
||||
async def render_tag_group_edit_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
|
||||
content = _tag_groups_edit_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_tag_group_edit_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
|
||||
)
|
||||
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
|
||||
tg_hdr)
|
||||
content = _tag_groups_edit_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers
|
||||
|
||||
278
blog/sxc/pages/__init__.py
Normal file
278
blog/sxc/pages/__init__.py
Normal 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
98
blog/sxc/pages/blog.sx
Normal 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))
|
||||
@@ -1,7 +1,7 @@
|
||||
{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
|
||||
<div class="flex flex-wrap gap-2 px-4 py-3">
|
||||
{% if has_access('blog.new_post') %}
|
||||
{% set new_href = url_for('blog.new_post')|host %}
|
||||
{% if has_access('blog.defpage_new_post') %}
|
||||
{% set new_href = url_for('blog.defpage_new_post')|host %}
|
||||
<a
|
||||
href="{{ new_href }}"
|
||||
sx-get="{{ new_href }}"
|
||||
@@ -14,7 +14,7 @@
|
||||
>
|
||||
<i class="fa fa-plus mr-1"></i> New Post
|
||||
</a>
|
||||
{% set new_page_href = url_for('blog.new_page')|host %}
|
||||
{% set new_page_href = url_for('blog.defpage_new_page')|host %}
|
||||
<a
|
||||
href="{{ new_page_href }}"
|
||||
sx-get="{{ new_page_href }}"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='tag-groups-edit-row', oob=oob) %}
|
||||
{% 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() %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='tag-groups-row', oob=oob) %}
|
||||
{% 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() %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<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">
|
||||
{{ group.name }}
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<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
|
||||
href="{{ new_href }}"
|
||||
sx-get="{{ new_href }}"
|
||||
@@ -19,7 +19,7 @@
|
||||
{% if drafts %}
|
||||
<div class="space-y-3">
|
||||
{% 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
|
||||
href="{{ edit_href }}"
|
||||
sx-disable
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='menu_items-row', oob=oob) %}
|
||||
{% 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() %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% endif %}
|
||||
{% set is_admin = (g.get("rights") or {}).get("admin") %}
|
||||
{% 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
|
||||
href="{{ edit_href }}"
|
||||
sx-get="{{ edit_href }}"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
{% endif %}
|
||||
|
||||
{# Admin link #}
|
||||
{% if post and has_access('blog.post.admin.admin') %}
|
||||
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{% if post and has_access('blog.post.admin.defpage_post_admin') %}
|
||||
{% 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>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
payments
|
||||
</a>
|
||||
</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
|
||||
{% endcall %}
|
||||
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
data
|
||||
{% endcall %}
|
||||
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
edit
|
||||
{% endcall %}
|
||||
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
settings
|
||||
{% endcall %}
|
||||
@@ -2,7 +2,7 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post-admin-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for('blog.post.admin.admin', slug=post.slug),
|
||||
url_for('blog.post.admin.defpage_post_admin', slug=post.slug),
|
||||
hx_select_search) %}
|
||||
{{ links.admin() }}
|
||||
{% endcall %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block ___app_title %}
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.menu_row() %}
|
||||
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_data', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-database" aria-hidden="true"></i>
|
||||
<div>
|
||||
data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
settings
|
||||
{% endcall %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_edit-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
|
||||
<div>
|
||||
edit
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_entries-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_entries', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-clock" aria-hidden="true"></i>
|
||||
<div>
|
||||
entries
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
|
||||
edit
|
||||
{% endcall %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post_settings-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search) %}
|
||||
{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
<div>
|
||||
settings
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours) }}
|
||||
{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours) }}
|
||||
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours) }}
|
||||
{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours) }}
|
||||
{{ admin_nav_item(url_for('menu_items.defpage_menu_items_page'), 'bars', 'Menu Items', select_colours) }}
|
||||
{{ admin_nav_item(url_for('snippets.defpage_snippets_page'), 'puzzle-piece', 'Snippets', select_colours) }}
|
||||
{{ admin_nav_item(url_for('blog.tag_groups_admin.defpage_tag_groups_page'), 'tags', 'Tag Groups', select_colours) }}
|
||||
{{ admin_nav_item(url_for('settings.defpage_cache_page'), 'refresh', 'Cache', select_colours) }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='cache-row', oob=oob) %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours, aclass='') }}
|
||||
{{ admin_nav_item(url_for('settings.defpage_cache_page'), 'refresh', 'Cache', select_colours, aclass='') }}
|
||||
{% call links.desktop_nav() %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='root-settings-row', oob=oob) %}
|
||||
{% call links.link(url_for('settings.home'), hx_select_search) %}
|
||||
{% call links.link(url_for('settings.defpage_settings_home'), hx_select_search) %}
|
||||
{{ links.admin() }}
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='snippets-row', oob=oob) %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }}
|
||||
{{ admin_nav_item(url_for('snippets.defpage_snippets_page'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }}
|
||||
{% call links.desktop_nav() %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
27
cart/app.py
27
cart/app.py
@@ -181,6 +181,12 @@ def create_app() -> "Quart":
|
||||
)
|
||||
g.page_config = _make_page_config(raw_pc) if raw_pc else None
|
||||
|
||||
# Setup defpage routes
|
||||
from sxc.pages import setup_cart_pages
|
||||
setup_cart_pages()
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
|
||||
# --- Blueprint registration ---
|
||||
# Static prefixes first, dynamic (page_slug) last
|
||||
|
||||
@@ -191,22 +197,19 @@ def create_app() -> "Quart":
|
||||
)
|
||||
|
||||
# Cart overview at GET /
|
||||
app.register_blueprint(
|
||||
register_cart_overview(url_prefix="/"),
|
||||
url_prefix="/",
|
||||
)
|
||||
overview_bp = register_cart_overview(url_prefix="/")
|
||||
mount_pages(overview_bp, "cart", names=["cart-overview"])
|
||||
app.register_blueprint(overview_bp, url_prefix="/")
|
||||
|
||||
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
|
||||
app.register_blueprint(
|
||||
register_page_admin(),
|
||||
url_prefix="/<page_slug>/admin",
|
||||
)
|
||||
admin_bp = register_page_admin()
|
||||
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
|
||||
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
|
||||
|
||||
# Page cart at /<page_slug>/ (dynamic, matched last)
|
||||
app.register_blueprint(
|
||||
register_page_cart(url_prefix="/"),
|
||||
url_prefix="/<page_slug>",
|
||||
)
|
||||
page_cart_bp = register_page_cart(url_prefix="/")
|
||||
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
|
||||
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
||||
# Redirect to overview for HTMX
|
||||
return redirect(url_for("cart_overview.overview"))
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
|
||||
return redirect(url_for("cart_overview.overview"))
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
|
||||
@bp.post("/quantity/<int:product_id>/")
|
||||
async def update_quantity(product_id: int):
|
||||
@@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
tickets = await get_ticket_cart_entries(g.s)
|
||||
|
||||
if not cart and not calendar_entries and not tickets:
|
||||
return redirect(url_for("cart_overview.overview"))
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
|
||||
product_total = total(cart) or 0
|
||||
calendar_amount = calendar_total(calendar_entries) or 0
|
||||
@@ -145,7 +145,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
cart_total = product_total + calendar_amount + ticket_amount
|
||||
|
||||
if cart_total <= 0:
|
||||
return redirect(url_for("cart_overview.overview"))
|
||||
return redirect(url_for("cart_overview.defpage_cart_overview"))
|
||||
|
||||
try:
|
||||
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
# bp/cart/overview_routes.py — Cart overview (list of page carts)
|
||||
# GET / handled by defpage.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, render_template, make_response
|
||||
from quart import Blueprint, g, request
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
from .services import get_cart_grouped_by_page
|
||||
|
||||
|
||||
def register(url_prefix: str) -> Blueprint:
|
||||
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
|
||||
|
||||
@bp.get("/")
|
||||
async def overview():
|
||||
from quart import g
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Load overview data for defpage route."""
|
||||
endpoint = request.endpoint or ""
|
||||
if not endpoint.endswith("defpage_cart_overview"):
|
||||
return
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_overview_page, render_overview_oob
|
||||
|
||||
from sx.sx_components import _overview_main_panel_sx
|
||||
page_groups = await get_cart_grouped_by_page(g.s)
|
||||
ctx = await get_template_context()
|
||||
|
||||
if not is_htmx_request():
|
||||
html = await render_overview_page(ctx, page_groups)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_overview_oob(ctx, page_groups)
|
||||
return sx_response(sx_src)
|
||||
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
|
||||
|
||||
return bp
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# bp/cart/page_routes.py — Per-page cart (view + checkout)
|
||||
# GET / handled by defpage.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, redirect, make_response, url_for
|
||||
from quart import Blueprint, g, redirect, make_response, url_for, request
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
from shared.infrastructure.actions import call_action
|
||||
from .services import (
|
||||
total,
|
||||
@@ -20,43 +19,25 @@ from .services import current_cart_identity
|
||||
def register(url_prefix: str) -> Blueprint:
|
||||
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
|
||||
|
||||
@bp.get("/")
|
||||
async def page_view():
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Load page cart data for defpage route."""
|
||||
endpoint = request.endpoint or ""
|
||||
if not endpoint.endswith("defpage_page_cart_view"):
|
||||
return
|
||||
post = g.page_post
|
||||
cart = await get_cart_for_page(g.s, post.id)
|
||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||
|
||||
ticket_groups = group_tickets(page_tickets)
|
||||
|
||||
tpl_ctx = dict(
|
||||
page_post=post,
|
||||
page_config=getattr(g, "page_config", None),
|
||||
cart=cart,
|
||||
calendar_cart_entries=cal_entries,
|
||||
ticket_cart_entries=page_tickets,
|
||||
ticket_groups=ticket_groups,
|
||||
total=total,
|
||||
calendar_total=calendar_total,
|
||||
ticket_total=ticket_total,
|
||||
)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_page_cart_page, render_page_cart_oob
|
||||
|
||||
from sx.sx_components import _page_cart_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_page_cart_page(
|
||||
ctx, post, cart, cal_entries, page_tickets,
|
||||
ticket_groups, total, calendar_total, ticket_total,
|
||||
)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_page_cart_oob(
|
||||
ctx, post, cart, cal_entries, page_tickets,
|
||||
ticket_groups, total, calendar_total, ticket_total,
|
||||
)
|
||||
return sx_response(sx_src)
|
||||
g.page_cart_content = _page_cart_main_panel_sx(
|
||||
ctx, cart, cal_entries, page_tickets, ticket_groups,
|
||||
total, calendar_total, ticket_total,
|
||||
)
|
||||
|
||||
@bp.post("/checkout/")
|
||||
async def page_checkout():
|
||||
@@ -67,7 +48,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||
|
||||
if not cart and not cal_entries and not page_tickets:
|
||||
return redirect(url_for("page_cart.page_view"))
|
||||
return redirect(url_for("page_cart.defpage_page_cart_view"))
|
||||
|
||||
product_total_val = total(cart) or 0
|
||||
calendar_amount = calendar_total(cal_entries) or 0
|
||||
@@ -75,7 +56,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
cart_total = product_total_val + calendar_amount + ticket_amount
|
||||
|
||||
if cart_total <= 0:
|
||||
return redirect(url_for("page_cart.page_view"))
|
||||
return redirect(url_for("page_cart.defpage_page_cart_view"))
|
||||
|
||||
ident = current_cart_identity()
|
||||
|
||||
|
||||
@@ -7,42 +7,28 @@ from quart import (
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("page_admin", __name__)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(**kwargs):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_cart_admin_page, render_cart_admin_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
page_post = getattr(g, "page_post", None)
|
||||
if not is_htmx_request():
|
||||
html = await render_cart_admin_page(ctx, page_post)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_cart_admin_oob(ctx, page_post)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/payments/")
|
||||
@require_admin
|
||||
async def payments(**kwargs):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_cart_payments_page, render_cart_payments_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
page_post = getattr(g, "page_post", None)
|
||||
if not is_htmx_request():
|
||||
html = await render_cart_payments_page(ctx, page_post)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_cart_payments_oob(ctx, page_post)
|
||||
return sx_response(sx_src)
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Pre-render admin content for defpage routes."""
|
||||
endpoint = request.endpoint or ""
|
||||
if request.method != "GET":
|
||||
return
|
||||
if endpoint.endswith("defpage_cart_admin"):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cart_admin_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
|
||||
elif endpoint.endswith("defpage_cart_payments"):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cart_payments_main_panel_sx
|
||||
ctx = await get_template_context()
|
||||
g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
|
||||
|
||||
@bp.put("/payments/")
|
||||
@require_admin
|
||||
|
||||
@@ -587,56 +587,6 @@ def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
|
||||
# Public API: Cart overview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_overview_page(ctx: dict, page_groups: list) -> str:
|
||||
"""Full page: cart overview."""
|
||||
main = _overview_main_panel_sx(page_groups, ctx)
|
||||
hdr = root_header_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=hdr, content=main)
|
||||
|
||||
|
||||
async def render_overview_oob(ctx: dict, page_groups: list) -> str:
|
||||
"""OOB response for cart overview."""
|
||||
main = _overview_main_panel_sx(page_groups, ctx)
|
||||
oobs = root_header_sx(ctx, oob=True)
|
||||
return oob_page_sx(oobs=oobs, content=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Page cart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_page_cart_page(ctx: dict, page_post: Any,
|
||||
cart: list, cal_entries: list, tickets: list,
|
||||
ticket_groups: list, total_fn: Any,
|
||||
cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||
"""Full page: page-specific cart."""
|
||||
main = _page_cart_main_panel_sx(ctx, cart, cal_entries, tickets, ticket_groups,
|
||||
total_fn, cal_total_fn, ticket_total_fn)
|
||||
hdr = root_header_sx(ctx)
|
||||
child = _cart_header_sx(ctx)
|
||||
page_hdr = _page_cart_header_sx(ctx, page_post)
|
||||
nested = sx_call(
|
||||
"header-child-sx",
|
||||
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
|
||||
)
|
||||
header_rows = "(<> " + hdr + " " + nested + ")"
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=main)
|
||||
|
||||
|
||||
async def render_page_cart_oob(ctx: dict, page_post: Any,
|
||||
cart: list, cal_entries: list, tickets: list,
|
||||
ticket_groups: list, total_fn: Any,
|
||||
cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||
"""OOB response for page cart."""
|
||||
main = _page_cart_main_panel_sx(ctx, cart, cal_entries, tickets, ticket_groups,
|
||||
total_fn, cal_total_fn, ticket_total_fn)
|
||||
child_oob = sx_call("oob-header-sx",
|
||||
parent_id="cart-header-child",
|
||||
row=SxExpr(_page_cart_header_sx(ctx, page_post)))
|
||||
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
|
||||
root_hdr_oob = root_header_sx(ctx, oob=True)
|
||||
oobs = "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=main)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -821,7 +771,7 @@ def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
def _cart_admin_main_panel_sx(ctx: dict) -> str:
|
||||
"""Admin overview panel -- links to sub-admin pages."""
|
||||
from quart import url_for
|
||||
payments_href = url_for("page_admin.payments")
|
||||
payments_href = url_for("page_admin.defpage_cart_payments")
|
||||
return (
|
||||
'(div :id "main-panel"'
|
||||
' (div :class "flex items-center justify-between p-3 border-b"'
|
||||
@@ -851,47 +801,6 @@ def _cart_payments_main_panel_sx(ctx: dict) -> str:
|
||||
checkout_prefix=checkout_prefix)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Cart page admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_cart_admin_page(ctx: dict, page_post: Any) -> str:
|
||||
"""Full page: cart page admin overview."""
|
||||
content = _cart_admin_main_panel_sx(ctx)
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_sx(ctx, page_post)
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
|
||||
"""OOB response: cart page admin overview."""
|
||||
content = _cart_admin_main_panel_sx(ctx)
|
||||
oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True)
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Cart payments admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_cart_payments_page(ctx: dict, page_post: Any) -> str:
|
||||
"""Full page: payments config."""
|
||||
content = _cart_payments_main_panel_sx(ctx)
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = await _post_header_sx(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected="payments")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str:
|
||||
"""OOB response: payments config."""
|
||||
content = _cart_payments_main_panel_sx(ctx)
|
||||
oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True, selected="payments")
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
def render_cart_payments_panel(ctx: dict) -> str:
|
||||
"""Render the payments config panel for PUT response."""
|
||||
|
||||
0
cart/sxc/__init__.py
Normal file
0
cart/sxc/__init__.py
Normal file
121
cart/sxc/pages/__init__.py
Normal file
121
cart/sxc/pages/__init__.py
Normal 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
25
cart/sxc/pages/cart.sx
Normal 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
360
docs/isomorphic-sx-plan.md
Normal 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
|
||||
@@ -78,6 +78,10 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# --- defpage setup ---
|
||||
from sxc.pages import setup_events_pages
|
||||
setup_events_pages()
|
||||
|
||||
# All events: / — global view across all pages
|
||||
app.register_blueprint(
|
||||
register_all_events(),
|
||||
@@ -169,11 +173,16 @@ def create_app() -> "Quart":
|
||||
|
||||
# Tickets blueprint — user-facing ticket views and QR codes
|
||||
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)
|
||||
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 ---
|
||||
@app.get("/oembed")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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():
|
||||
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 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()
|
||||
if not is_htmx_request():
|
||||
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)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "events", names=["calendar-admin"])
|
||||
|
||||
@bp.get("/description/")
|
||||
@require_admin
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
# ---------- Pages ----------
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(entry_id: int, **kwargs):
|
||||
@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 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
|
||||
|
||||
@@ -238,20 +238,18 @@ def register():
|
||||
"user_ticket_counts_by_type": user_ticket_counts_by_type,
|
||||
"container_nav": container_nav,
|
||||
}
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def get(entry_id: int, **rest):
|
||||
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 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()
|
||||
if not is_htmx_request():
|
||||
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)
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "events", names=["entry-detail"])
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_admin
|
||||
@@ -435,10 +433,10 @@ def register():
|
||||
nav_oob = await get_day_nav_oob(year, month, day)
|
||||
|
||||
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()
|
||||
html = await render_entry_page(tctx)
|
||||
html = _entry_main_panel_html(tctx)
|
||||
return sx_response(html + nav_oob)
|
||||
|
||||
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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():
|
||||
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
|
||||
from sx.sx_components import render_day_admin_page, render_day_admin_oob
|
||||
@bp.before_request
|
||||
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
|
||||
|
||||
@@ -9,9 +9,8 @@ from .services.markets import (
|
||||
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.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
|
||||
@@ -22,18 +21,17 @@ def register():
|
||||
async def inject_root():
|
||||
return {}
|
||||
|
||||
@bp.get("/")
|
||||
async def home(**kwargs):
|
||||
@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 sx.sx_components import render_markets_page, render_markets_oob
|
||||
|
||||
from sx.sx_components import _markets_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_markets_page(ctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_markets_oob(ctx)
|
||||
return sx_response(sx_src)
|
||||
g.markets_content = _markets_main_panel_html(ctx)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "events", names=["events-markets"])
|
||||
|
||||
@bp.post("/new/")
|
||||
@require_admin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
request, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
@@ -23,33 +23,32 @@ from shared.browser.app.utils import (
|
||||
parse_time,
|
||||
parse_cost
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def get(slot_id: int, **kwargs):
|
||||
slot = await svc_get_slot(g.s, slot_id)
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
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:
|
||||
return await make_response("Not found", 404)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_slot_page, render_slot_oob
|
||||
from quart import abort
|
||||
abort(404)
|
||||
g.slot = slot
|
||||
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()
|
||||
if not is_htmx_request():
|
||||
html = await render_slot_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_slot_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
@bp.context_processor
|
||||
async def _inject_slot():
|
||||
return {"slot": getattr(g, "slot", None)}
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "events", names=["slot-detail"])
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_admin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
request, Blueprint, g, jsonify
|
||||
)
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -19,21 +19,16 @@ from shared.browser.app.utils import (
|
||||
parse_time,
|
||||
parse_cost
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("slots", __name__, url_prefix='/slots')
|
||||
|
||||
# ---------- Pages ----------
|
||||
|
||||
|
||||
bp.register_blueprint(
|
||||
register_slot()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@bp.context_processor
|
||||
async def get_slots():
|
||||
calendar = getattr(g, "calendar", None)
|
||||
@@ -43,19 +38,17 @@ def register():
|
||||
}
|
||||
return {"slots": []}
|
||||
|
||||
@bp.get("/")
|
||||
async def get(**kwargs):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_slots_page, render_slots_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_slots_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_slots_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
return
|
||||
calendar = getattr(g, "calendar", None)
|
||||
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
|
||||
from sx.sx_components import render_slots_table
|
||||
g.slots_content = render_slots_table(slots, calendar)
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "events", names=["slots-listing"])
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from quart import (
|
||||
Blueprint, g, request, render_template, make_response, jsonify,
|
||||
Blueprint, g, request, make_response,
|
||||
)
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -34,12 +34,10 @@ logger = logging.getLogger(__name__)
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def dashboard():
|
||||
"""Ticket admin dashboard with QR scanner and recent tickets."""
|
||||
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
|
||||
# Get recent tickets
|
||||
result = await g.s.execute(
|
||||
select(Ticket)
|
||||
@@ -72,15 +70,9 @@ def register() -> Blueprint:
|
||||
}
|
||||
|
||||
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()
|
||||
if not is_htmx_request():
|
||||
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)
|
||||
g.ticket_admin_content = _ticket_admin_main_panel_html(ctx, tickets, stats)
|
||||
|
||||
@bp.get("/entry/<int:entry_id>/")
|
||||
@require_admin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -16,30 +16,37 @@ from .services.ticket import (
|
||||
from ..ticket_types.services.tickets import (
|
||||
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
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def get(ticket_type_id: int, **kwargs):
|
||||
"""View a single ticket type."""
|
||||
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id)
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
return
|
||||
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:
|
||||
return await make_response("Not found", 404)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_ticket_type_page, render_ticket_type_oob
|
||||
from quart import abort
|
||||
abort(404)
|
||||
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()
|
||||
if not is_htmx_request():
|
||||
html = await render_ticket_type_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_ticket_type_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
@bp.context_processor
|
||||
async def _inject_ticket_type():
|
||||
return {"ticket_type": getattr(g, "ticket_type", None)}
|
||||
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "events", names=["ticket-type-detail"])
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_admin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
request, Blueprint, g, jsonify
|
||||
)
|
||||
|
||||
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 shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
|
||||
@@ -36,19 +35,22 @@ def register():
|
||||
}
|
||||
return {"ticket_types": []}
|
||||
|
||||
@bp.get("/")
|
||||
async def get(**kwargs):
|
||||
"""List all ticket types for the current entry."""
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_ticket_types_page, render_ticket_types_oob
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
if "defpage_" not in (request.endpoint or ""):
|
||||
return
|
||||
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()
|
||||
if not is_htmx_request():
|
||||
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)
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "events", names=["ticket-types-listing"])
|
||||
|
||||
@bp.post("/")
|
||||
@require_admin
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from quart import (
|
||||
Blueprint, g, request, render_template, make_response,
|
||||
Blueprint, g, request, make_response,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -39,59 +39,43 @@ logger = logging.getLogger(__name__)
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
|
||||
|
||||
@bp.get("/")
|
||||
async def my_tickets():
|
||||
"""List all tickets for the current user/session."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
ident = current_cart_identity()
|
||||
tickets = await get_user_tickets(
|
||||
g.s,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
)
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_tickets_page, render_tickets_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_tickets_page(ctx, tickets)
|
||||
return await make_response(html, 200)
|
||||
else:
|
||||
sx_src = await render_tickets_oob(ctx, tickets)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/<code>/")
|
||||
async def ticket_detail(code: str):
|
||||
"""View a single ticket with QR code."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
ticket = await get_ticket_by_code(g.s, code)
|
||||
if not ticket:
|
||||
return await make_response("Ticket not found", 404)
|
||||
|
||||
# Verify ownership
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"] is not None:
|
||||
if ticket.user_id != ident["user_id"]:
|
||||
return await make_response("Ticket not found", 404)
|
||||
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.before_request
|
||||
async def _prepare_page_data():
|
||||
ep = request.endpoint or ""
|
||||
if "defpage_my_tickets" in ep:
|
||||
ident = current_cart_identity()
|
||||
tickets = await get_user_tickets(
|
||||
g.s,
|
||||
user_id=ident["user_id"],
|
||||
session_id=ident["session_id"],
|
||||
)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _tickets_main_panel_html
|
||||
ctx = await get_template_context()
|
||||
g.tickets_content = _tickets_main_panel_html(ctx, tickets)
|
||||
elif "defpage_ticket_detail" in ep:
|
||||
code = (request.view_args or {}).get("code")
|
||||
ticket = await get_ticket_by_code(g.s, code) if code else None
|
||||
if not ticket:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
# Verify ownership
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"] is not None:
|
||||
if ticket.user_id != ident["user_id"]:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
elif ident["session_id"] is not None:
|
||||
if ticket.session_id != ident["session_id"]:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
else:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _ticket_detail_panel_html
|
||||
ctx = await get_template_context()
|
||||
g.ticket_detail_content = _ticket_detail_panel_html(ctx, ticket)
|
||||
|
||||
@bp.post("/buy/")
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
|
||||
@@ -191,11 +191,11 @@ def _calendar_nav_sx(ctx: dict) -> str:
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
|
||||
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",
|
||||
label="Slots", select_colours=select_colours))
|
||||
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",
|
||||
select_colours=select_colours))
|
||||
return "".join(parts)
|
||||
@@ -319,7 +319,7 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
nav_parts = []
|
||||
if cal_slug:
|
||||
for endpoint, label in [
|
||||
("calendar.slots.get", "slots"),
|
||||
("calendar.slots.defpage_slots_listing", "slots"),
|
||||
("calendar.admin.calendar_description_edit", "description"),
|
||||
]:
|
||||
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:
|
||||
"""Build the markets section header row."""
|
||||
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,
|
||||
link_href=link_href,
|
||||
link_label_content=SxExpr(sx_call("events-markets-label")),
|
||||
@@ -594,7 +594,7 @@ def _day_row_html(ctx: dict, entry) -> str:
|
||||
# Slot/Time
|
||||
slot = getattr(entry, "slot", None)
|
||||
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_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
|
||||
slot_html = sx_call("events-day-row-slot",
|
||||
@@ -774,7 +774,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
|
||||
ticket_cards = []
|
||||
if 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_name = entry.name if entry else "Unknown event"
|
||||
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"}
|
||||
header_bg = bg_map.get(state, "bg-stone-50")
|
||||
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 = _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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
# Calendar admin helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
# ===========================================================================
|
||||
@@ -1939,36 +1770,6 @@ def _entry_nav_html(ctx: dict) -> str:
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2364,7 +2165,7 @@ def render_slots_table(slots, calendar) -> str:
|
||||
rows_html = ""
|
||||
if 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)
|
||||
desc = getattr(s, "description", "") or ""
|
||||
|
||||
@@ -2508,7 +2309,7 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str:
|
||||
|
||||
tickets_html = ""
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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"),
|
||||
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"))
|
||||
cart_icon = sx_call("events-adjust-cart-icon",
|
||||
href=my_tickets_href, count=str(count))
|
||||
@@ -2960,40 +2761,6 @@ def _entry_admin_main_panel_html(ctx: dict) -> str:
|
||||
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)
|
||||
# ===========================================================================
|
||||
@@ -3027,45 +2794,6 @@ def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
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
|
||||
# ===========================================================================
|
||||
@@ -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)
|
||||
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
|
||||
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
|
||||
# ===========================================================================
|
||||
|
||||
406
events/sxc/pages/__init__.py
Normal file
406
events/sxc/pages/__init__.py
Normal 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", "")
|
||||
89
events/sxc/pages/events.sx
Normal file
89
events/sxc/pages/events.sx
Normal 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))
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Desktop nav -->
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% 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,
|
||||
select_colours,
|
||||
True,
|
||||
@@ -14,5 +14,5 @@
|
||||
{% endcall %}
|
||||
{% if g.rights.admin %}
|
||||
{% 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 %}
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="text-xs font-medium">
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'calendar.slots.slot.get',
|
||||
'calendar.slots.slot.defpage_slot_detail',
|
||||
calendar_slug=calendar.slug,
|
||||
slot_id=entry.slot.id
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% 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>
|
||||
<div>
|
||||
Markets
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<a
|
||||
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">
|
||||
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
|
||||
@@ -166,7 +166,7 @@
|
||||
|
||||
<a
|
||||
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">
|
||||
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="space-y-2 mb-4">
|
||||
{% for ticket in created_tickets %}
|
||||
<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"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<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"
|
||||
>
|
||||
View all my tickets
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section id="ticket-detail" class="{{styles.list_container}} max-w-lg mx-auto">
|
||||
|
||||
{# 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">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true"></i>
|
||||
Back to my tickets
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="space-y-4">
|
||||
{% for ticket in tickets %}
|
||||
<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"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
|
||||
@@ -84,11 +84,20 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# --- defpage setup ---
|
||||
from sxc.pages import setup_federation_pages
|
||||
setup_federation_pages()
|
||||
|
||||
# --- blueprints ---
|
||||
# Well-known + actors (webfinger, inbox, outbox, etc.) are now handled
|
||||
# by the shared AP blueprint registered in create_base_app().
|
||||
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())
|
||||
|
||||
# --- home page ---
|
||||
|
||||
@@ -32,18 +32,103 @@ def register(url_prefix="/social"):
|
||||
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
|
||||
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("/")
|
||||
async def home_timeline():
|
||||
if not g.get("user"):
|
||||
return redirect(url_for("auth.login_form"))
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_home_timeline(g.s, actor.id)
|
||||
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, "home", actor)
|
||||
if endpoint.endswith("defpage_home_timeline"):
|
||||
actor = _require_actor()
|
||||
items = await services.federation.get_home_timeline(g.s, actor.id)
|
||||
from sx.sx_components import _timeline_content_sx
|
||||
g.home_timeline_content = _timeline_content_sx(items, "home", actor)
|
||||
|
||||
elif endpoint.endswith("defpage_public_timeline"):
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
items = await services.federation.get_public_timeline(g.s)
|
||||
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")
|
||||
async def home_timeline_page():
|
||||
@@ -62,15 +147,6 @@ def register(url_prefix="/social"):
|
||||
sx_src = await render_timeline_items(items, "home", actor)
|
||||
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")
|
||||
async def public_timeline_page():
|
||||
before_str = request.args.get("before")
|
||||
@@ -86,16 +162,7 @@ def register(url_prefix="/social"):
|
||||
sx_src = await render_timeline_items(items, "public", actor)
|
||||
return sx_response(sx_src)
|
||||
|
||||
# -- 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)
|
||||
# -- Compose ---------------------------------------------------------------
|
||||
|
||||
@bp.post("/compose")
|
||||
async def compose_submit():
|
||||
@@ -103,7 +170,7 @@ def register(url_prefix="/social"):
|
||||
form = await request.form
|
||||
content = form.get("content", "").strip()
|
||||
if not content:
|
||||
return redirect(url_for("social.compose_form"))
|
||||
return redirect(url_for("social.defpage_compose_form"))
|
||||
|
||||
visibility = form.get("visibility", "public")
|
||||
in_reply_to = form.get("in_reply_to") or None
|
||||
@@ -114,45 +181,26 @@ def register(url_prefix="/social"):
|
||||
visibility=visibility,
|
||||
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>")
|
||||
async def delete_post(post_id: int):
|
||||
actor = _require_actor()
|
||||
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 ------------------------------------------------------
|
||||
|
||||
@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)
|
||||
# -- Search + Follow -------------------------------------------------------
|
||||
|
||||
@bp.get("/search/page")
|
||||
async def search_page():
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
query = request.args.get("q", "").strip()
|
||||
page = request.args.get("page", 1, type=int)
|
||||
actors = []
|
||||
actors_list = []
|
||||
total = 0
|
||||
followed_urls: set[str] = set()
|
||||
if query:
|
||||
actors, total = await services.federation.search_actors(
|
||||
actors_list, total = await services.federation.search_actors(
|
||||
g.s, query, page=page,
|
||||
)
|
||||
if actor:
|
||||
@@ -161,7 +209,7 @@ def register(url_prefix="/social"):
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
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)
|
||||
|
||||
@bp.post("/follow")
|
||||
@@ -175,7 +223,7 @@ def register(url_prefix="/social"):
|
||||
)
|
||||
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 redirect(request.referrer or url_for("social.search"))
|
||||
return redirect(request.referrer or url_for("social.defpage_search"))
|
||||
|
||||
@bp.post("/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"):
|
||||
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):
|
||||
"""Re-render a single actor card after follow/unfollow via HTMX."""
|
||||
@@ -198,7 +246,6 @@ def register(url_prefix="/social"):
|
||||
if not remote_dto:
|
||||
return Response("", status=200)
|
||||
followed_urls = {remote_actor_url} if is_followed else set()
|
||||
# Detect list context from referer
|
||||
referer = request.referrer or ""
|
||||
if "/followers" in referer:
|
||||
list_type = "followers"
|
||||
@@ -207,7 +254,7 @@ def register(url_prefix="/social"):
|
||||
from sx.sx_components import render_actor_card
|
||||
return sx_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
|
||||
|
||||
# -- Interactions ---------------------------------------------------------
|
||||
# -- Interactions ----------------------------------------------------------
|
||||
|
||||
@bp.post("/like")
|
||||
async def like():
|
||||
@@ -216,7 +263,6 @@ def register(url_prefix="/social"):
|
||||
object_id = form.get("object_id", "")
|
||||
author_inbox = form.get("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)
|
||||
|
||||
@bp.post("/unlike")
|
||||
@@ -250,7 +296,6 @@ def register(url_prefix="/social"):
|
||||
"""Re-render interaction buttons after a like/boost action."""
|
||||
from shared.models.federation import APInteraction, APRemotePost, APActivity
|
||||
from sqlalchemy import select
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
|
||||
svc = services.federation
|
||||
post_type, post_id = await svc._resolve_post(g.s, object_id)
|
||||
@@ -304,51 +349,24 @@ def register(url_prefix="/social"):
|
||||
actor=actor,
|
||||
))
|
||||
|
||||
# -- Following / Followers ------------------------------------------------
|
||||
|
||||
@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)
|
||||
# -- Following / Followers pagination --------------------------------------
|
||||
|
||||
@bp.get("/following/page")
|
||||
async def following_list_page():
|
||||
actor = _require_actor()
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
@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")
|
||||
async def followers_list_page():
|
||||
actor = _require_actor()
|
||||
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,
|
||||
)
|
||||
following, _ = await services.federation.get_following(
|
||||
@@ -356,43 +374,9 @@ def register(url_prefix="/social"):
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
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)
|
||||
|
||||
@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")
|
||||
async def actor_timeline_page(id: int):
|
||||
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)
|
||||
return sx_response(sx_src)
|
||||
|
||||
# -- 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)
|
||||
# -- Notifications ---------------------------------------------------------
|
||||
|
||||
@bp.get("/notifications/count")
|
||||
async def notification_count():
|
||||
@@ -440,6 +414,6 @@ def register(url_prefix="/social"):
|
||||
async def mark_read():
|
||||
actor = _require_actor()
|
||||
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
|
||||
|
||||
@@ -35,12 +35,12 @@ def _social_nav_sx(actor: Any) -> str:
|
||||
return sx_call("federation-nav-choose-username", url=choose_url)
|
||||
|
||||
links = [
|
||||
("social.home_timeline", "Timeline"),
|
||||
("social.public_timeline", "Public"),
|
||||
("social.compose_form", "Compose"),
|
||||
("social.following_list", "Following"),
|
||||
("social.followers_list", "Followers"),
|
||||
("social.search", "Search"),
|
||||
("social.defpage_home_timeline", "Timeline"),
|
||||
("social.defpage_public_timeline", "Public"),
|
||||
("social.defpage_compose_form", "Compose"),
|
||||
("social.defpage_following_list", "Following"),
|
||||
("social.defpage_followers_list", "Followers"),
|
||||
("social.defpage_search", "Search"),
|
||||
]
|
||||
|
||||
parts = []
|
||||
@@ -51,7 +51,7 @@ def _social_nav_sx(actor: Any) -> str:
|
||||
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
|
||||
|
||||
# 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_bold = " font-bold" if request.path == notif_url else ""
|
||||
parts.append(sx_call(
|
||||
@@ -122,7 +122,7 @@ def _interaction_buttons_sx(item: Any, actor: Any) -> str:
|
||||
boost_action = url_for("social.boost")
|
||||
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 ""
|
||||
|
||||
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:
|
||||
name_sx = sx_call(
|
||||
"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)),
|
||||
)
|
||||
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,
|
||||
actor: Any) -> str:
|
||||
"""Full page: timeline (home or public)."""
|
||||
def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str:
|
||||
"""Build timeline content SX string."""
|
||||
from quart import url_for
|
||||
|
||||
label = "Home" if timeline_type == "home" else "Public"
|
||||
compose_sx = ""
|
||||
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)
|
||||
|
||||
timeline_sx = _timeline_items_sx(items, timeline_type, actor)
|
||||
|
||||
content = sx_call(
|
||||
return sx_call(
|
||||
"federation-timeline-page",
|
||||
label=label,
|
||||
compose=SxExpr(compose_sx) if compose_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,
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Compose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> str:
|
||||
"""Full page: compose form."""
|
||||
def _compose_content_sx(actor: Any, reply_to: str | None) -> str:
|
||||
"""Build compose form content SX string."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
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)),
|
||||
)
|
||||
|
||||
content = sx_call(
|
||||
return sx_call(
|
||||
"federation-compose-form",
|
||||
action=action, csrf=csrf,
|
||||
reply=SxExpr(reply_sx) if reply_sx else None,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title="Compose \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_search_page(ctx: dict, query: str, actors: list, total: int,
|
||||
page: int, followed_urls: set, actor: Any) -> str:
|
||||
"""Full page: search."""
|
||||
def _search_content_sx(query: str, actors: list, total: int,
|
||||
page: int, followed_urls: set, actor: Any) -> str:
|
||||
"""Build search page content SX string."""
|
||||
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")
|
||||
|
||||
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>",
|
||||
)
|
||||
|
||||
content = sx_call(
|
||||
return sx_call(
|
||||
"federation-search-page",
|
||||
search_url=search_url, search_page_url=search_page_url,
|
||||
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,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title="Search \u2014 Rose Ash")
|
||||
|
||||
|
||||
async def render_search_results(actors: list, query: str, page: int,
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Following / Followers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_following_page(ctx: dict, actors: list, total: int,
|
||||
actor: Any) -> str:
|
||||
"""Full page: following list."""
|
||||
def _following_content_sx(actors: list, total: int, actor: Any) -> str:
|
||||
"""Build following list content SX string."""
|
||||
items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor)
|
||||
content = sx_call(
|
||||
return sx_call(
|
||||
"federation-actor-list-page",
|
||||
title="Following", count_str=f"({total})",
|
||||
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:
|
||||
@@ -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)
|
||||
|
||||
|
||||
async def render_followers_page(ctx: dict, actors: list, total: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Full page: followers list."""
|
||||
def _followers_content_sx(actors: list, total: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Build followers list content SX string."""
|
||||
items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor)
|
||||
content = sx_call(
|
||||
return sx_call(
|
||||
"federation-actor-list-page",
|
||||
title="Followers", count_str=f"({total})",
|
||||
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,
|
||||
@@ -586,13 +559,9 @@ async def render_followers_items(actors: list, page: int,
|
||||
return _actor_list_items_sx(actors, page, "followers", followed_urls, actor)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Actor timeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
|
||||
is_following: bool, actor: Any) -> str:
|
||||
"""Full page: remote actor timeline."""
|
||||
def _actor_timeline_content_sx(remote_actor: Any, items: list,
|
||||
is_following: bool, actor: Any) -> str:
|
||||
"""Build actor timeline content SX string."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
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,
|
||||
)
|
||||
|
||||
content = sx_call(
|
||||
return sx_call(
|
||||
"federation-actor-timeline-layout",
|
||||
header=SxExpr(header_sx),
|
||||
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,
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_notifications_page(ctx: dict, notifications: list,
|
||||
actor: Any) -> str:
|
||||
"""Full page: notifications."""
|
||||
def _notifications_content_sx(notifications: list) -> str:
|
||||
"""Build notifications content SX string."""
|
||||
if not notifications:
|
||||
notif_sx = sx_call("empty-state", message="No notifications yet.",
|
||||
cls="text-stone-500")
|
||||
@@ -673,9 +634,7 @@ async def render_notifications_page(ctx: dict, notifications: list,
|
||||
items=SxExpr(items_sx),
|
||||
)
|
||||
|
||||
content = sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title="Notifications \u2014 Rose Ash")
|
||||
return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
109
federation/sxc/pages/__init__.py
Normal file
109
federation/sxc/pages/__init__.py
Normal 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", "")
|
||||
49
federation/sxc/pages/social.sx
Normal file
49
federation/sxc/pages/social.sx
Normal 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))
|
||||
@@ -4,32 +4,32 @@
|
||||
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
|
||||
{% if actor %}
|
||||
<nav class="flex gap-3 text-sm items-center flex-wrap">
|
||||
<a href="{{ url_for('social.home_timeline') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.home_timeline') %}font-bold{% endif %}">
|
||||
<a href="{{ url_for('social.defpage_home_timeline') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_home_timeline') %}font-bold{% endif %}">
|
||||
Timeline
|
||||
</a>
|
||||
<a href="{{ url_for('social.public_timeline') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.public_timeline') %}font-bold{% endif %}">
|
||||
<a href="{{ url_for('social.defpage_public_timeline') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_public_timeline') %}font-bold{% endif %}">
|
||||
Public
|
||||
</a>
|
||||
<a href="{{ url_for('social.compose_form') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.compose_form') %}font-bold{% endif %}">
|
||||
<a href="{{ url_for('social.defpage_compose_form') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_compose_form') %}font-bold{% endif %}">
|
||||
Compose
|
||||
</a>
|
||||
<a href="{{ url_for('social.following_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.following_list') %}font-bold{% endif %}">
|
||||
<a href="{{ url_for('social.defpage_following_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_following_list') %}font-bold{% endif %}">
|
||||
Following
|
||||
</a>
|
||||
<a href="{{ url_for('social.followers_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.followers_list') %}font-bold{% endif %}">
|
||||
<a href="{{ url_for('social.defpage_followers_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_followers_list') %}font-bold{% endif %}">
|
||||
Followers
|
||||
</a>
|
||||
<a href="{{ url_for('social.search') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.search') %}font-bold{% endif %}">
|
||||
<a href="{{ url_for('social.defpage_search') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_search') %}font-bold{% endif %}">
|
||||
Search
|
||||
</a>
|
||||
<a href="{{ url_for('social.notifications') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 relative {% if request.path == url_for('social.notifications') %}font-bold{% endif %}">
|
||||
<a href="{{ url_for('social.defpage_notifications') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 relative {% if request.path == url_for('social.defpage_notifications') %}font-bold{% endif %}">
|
||||
Notifications
|
||||
<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>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
{% 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>
|
||||
{% else %}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
{% 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>
|
||||
{% else %}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<form method="get" action="{{ url_for('social.search') }}" class="mb-6"
|
||||
sx-get="{{ url_for('social.search_page') }}"
|
||||
<form method="get" action="{{ url_for('social.defpage_search') }}" class="mb-6"
|
||||
sx-get="{{ url_for('social.defpage_search_page') }}"
|
||||
sx-target="#search-results"
|
||||
sx-push-url="{{ url_for('social.search') }}">
|
||||
sx-push-url="{{ url_for('social.defpage_search') }}">
|
||||
<div class="flex gap-2">
|
||||
<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"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ "Home" if timeline_type == "home" else "Public" }} Timeline</h1>
|
||||
{% 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">
|
||||
Compose
|
||||
</a>
|
||||
|
||||
@@ -99,25 +99,30 @@ def create_app() -> "Quart":
|
||||
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
|
||||
app.register_blueprint(
|
||||
register_all_markets(),
|
||||
url_prefix="/",
|
||||
)
|
||||
all_markets_bp = register_all_markets()
|
||||
mount_pages(all_markets_bp, "market", names=["all-markets-index"])
|
||||
app.register_blueprint(all_markets_bp, url_prefix="/")
|
||||
|
||||
# Page markets: /<slug>/ — markets for a single page
|
||||
app.register_blueprint(
|
||||
register_page_markets(),
|
||||
url_prefix="/<slug>",
|
||||
)
|
||||
page_markets_bp = register_page_markets()
|
||||
mount_pages(page_markets_bp, "market", names=["page-markets-index"])
|
||||
app.register_blueprint(page_markets_bp, url_prefix="/<slug>")
|
||||
|
||||
# Page admin: /<slug>/admin/ — post-level admin for markets
|
||||
app.register_blueprint(
|
||||
register_page_admin(),
|
||||
url_prefix="/<slug>/admin",
|
||||
)
|
||||
page_admin_bp = register_page_admin()
|
||||
mount_pages(page_admin_bp, "market", names=["page-admin"])
|
||||
app.register_blueprint(page_admin_bp, url_prefix="/<slug>/admin")
|
||||
|
||||
# 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(
|
||||
register_market_bp(
|
||||
url_prefix="/",
|
||||
|
||||
@@ -2,70 +2,57 @@
|
||||
All-markets blueprint — shows markets across ALL pages.
|
||||
|
||||
Mounted at / (root of market app). No slug context.
|
||||
|
||||
Routes:
|
||||
GET / — full page with first page of markets
|
||||
GET /all-markets — HTMX fragment for infinite scroll
|
||||
GET / handled by defpage. GET /all-markets is pagination fragment.
|
||||
"""
|
||||
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.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import PostDTO, dto_from_dict
|
||||
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:
|
||||
bp = Blueprint("all_markets", __name__)
|
||||
|
||||
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
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Load all-markets data for defpage routes."""
|
||||
endpoint = request.endpoint or ""
|
||||
if not endpoint.endswith("defpage_all_markets_index"):
|
||||
return
|
||||
page = int(request.args.get("page", 1))
|
||||
markets, has_more, page_info = await _load_markets(page)
|
||||
|
||||
ctx = dict(
|
||||
markets=markets,
|
||||
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)
|
||||
g.all_markets_data = {
|
||||
"markets": markets, "has_more": has_more,
|
||||
"page_info": page_info, "page": page,
|
||||
}
|
||||
|
||||
@bp.get("/all-markets")
|
||||
async def markets_fragment():
|
||||
|
||||
@@ -5,17 +5,13 @@ from quart import (
|
||||
g,
|
||||
Blueprint,
|
||||
abort,
|
||||
render_template,
|
||||
render_template_string,
|
||||
make_response,
|
||||
current_app,
|
||||
)
|
||||
from shared.config import config
|
||||
from .services.nav import category_context, get_nav
|
||||
from .services.blacklist.category import is_category_blocked
|
||||
|
||||
from .services import (
|
||||
_hx_fragment_request,
|
||||
_productInfo,
|
||||
_vary,
|
||||
_current_url_without_page,
|
||||
@@ -33,27 +29,9 @@ def register():
|
||||
register_product(),
|
||||
)
|
||||
|
||||
@browse_bp.get("/")
|
||||
@cache_page(tag="browse")
|
||||
async def 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)
|
||||
# Mount defpage for market home (GET /)
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(browse_bp, "market", names=["market-home"])
|
||||
|
||||
@browse_bp.get("/all/")
|
||||
@cache_page(tag="browse")
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import (
|
||||
render_template, make_response, Blueprint
|
||||
)
|
||||
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
def register():
|
||||
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
|
||||
from sx.sx_components import render_market_admin_page, render_market_admin_oob
|
||||
# Mount defpage for market admin (GET /)
|
||||
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
|
||||
|
||||
@@ -6,7 +6,6 @@ import unicodedata
|
||||
from quart import make_response, request, g, Blueprint
|
||||
|
||||
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.sx.helpers import sx_response
|
||||
|
||||
@@ -27,19 +26,16 @@ def _slugify(value: str, max_len: int = 255) -> str:
|
||||
def register():
|
||||
bp = Blueprint("page_admin", __name__)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(**kwargs):
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""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 sx.sx_components import render_page_admin_page, render_page_admin_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
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)
|
||||
from sx.sx_components import _markets_admin_panel_sx
|
||||
ctx = await get_template_context()
|
||||
g.page_admin_content = await _markets_admin_panel_sx(ctx)
|
||||
|
||||
@bp.post("/new/")
|
||||
@require_admin
|
||||
|
||||
@@ -2,55 +2,40 @@
|
||||
Page-markets blueprint — shows markets for a single page.
|
||||
|
||||
Mounted at /<slug> (page-scoped). Requires g.post_data from hydrate_post.
|
||||
|
||||
Routes:
|
||||
GET /<slug>/ — full page scoped to this page
|
||||
GET /<slug>/page-markets — HTMX fragment for infinite scroll
|
||||
GET / handled by defpage. GET /page-markets is pagination fragment.
|
||||
"""
|
||||
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.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:
|
||||
bp = Blueprint("page_markets", __name__)
|
||||
|
||||
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
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
@bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Load page-markets data for defpage routes."""
|
||||
endpoint = request.endpoint or ""
|
||||
if not endpoint.endswith("defpage_page_markets_index"):
|
||||
return
|
||||
post = g.post_data["post"]
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
markets, has_more = await _load_markets(post["id"], page)
|
||||
|
||||
ctx = dict(
|
||||
markets=markets,
|
||||
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)
|
||||
g.page_markets_data = {
|
||||
"markets": markets, "has_more": has_more,
|
||||
"page": page, "post_slug": post.get("slug", ""),
|
||||
}
|
||||
|
||||
@bp.get("/page-markets")
|
||||
async def markets_fragment():
|
||||
|
||||
@@ -111,7 +111,7 @@ def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
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
|
||||
categories = ctx.get("categories", {})
|
||||
@@ -159,7 +159,7 @@ def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str,
|
||||
|
||||
admin_sx = ""
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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,
|
||||
page_info: dict, page: int) -> str:
|
||||
"""Pagination fragment: all markets cards."""
|
||||
@@ -1258,54 +1218,6 @@ async def render_all_markets_cards(markets: list, has_more: bool,
|
||||
# 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,
|
||||
page: int, post_slug: str) -> str:
|
||||
"""Pagination fragment: page-scoped markets cards."""
|
||||
@@ -1322,31 +1234,6 @@ async def render_page_markets_cards(markets: list, has_more: bool,
|
||||
# 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:
|
||||
"""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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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:
|
||||
"""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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
0
market/sxc/__init__.py
Normal file
0
market/sxc/__init__.py
Normal file
170
market/sxc/pages/__init__.py
Normal file
170
market/sxc/pages/__init__.py
Normal 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"'
|
||||
37
market/sxc/pages/market.sx
Normal file
37
market/sxc/pages/market.sx
Normal 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))
|
||||
@@ -70,16 +70,22 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# Load orders-specific s-expression components
|
||||
from sx.sx_components import load_orders_components
|
||||
load_orders_components()
|
||||
# Load orders-specific s-expression components (loaded at import time)
|
||||
import sx.sx_components # noqa: F811
|
||||
|
||||
# Setup defpage routes
|
||||
from sxc.pages import setup_orders_pages
|
||||
setup_orders_pages()
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
# Orders list at /
|
||||
app.register_blueprint(register_orders(url_prefix="/"))
|
||||
# Orders list at / (defpage routes mounted below)
|
||||
bp = register_orders(url_prefix="/")
|
||||
from shared.sx.pages import mount_pages
|
||||
mount_pages(bp, "orders")
|
||||
app.register_blueprint(bp)
|
||||
|
||||
# Checkout webhook + return
|
||||
app.register_blueprint(register_checkout())
|
||||
|
||||
@@ -2,16 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, redirect, url_for, make_response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.models.order import Order
|
||||
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||
from shared.config import config
|
||||
|
||||
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 shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
from .filters.qs import makeqs_factory, decode
|
||||
|
||||
@@ -33,34 +30,6 @@ def register() -> Blueprint:
|
||||
def route():
|
||||
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/")
|
||||
async def order_pay(order_id: int):
|
||||
"""Re-open the SumUp payment page for this order."""
|
||||
@@ -73,7 +42,7 @@ def register() -> Blueprint:
|
||||
return await make_response("Order not found", 404)
|
||||
|
||||
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:
|
||||
return redirect(order.sumup_hosted_url)
|
||||
@@ -120,13 +89,13 @@ def register() -> Blueprint:
|
||||
return await make_response("Order not found", 404)
|
||||
|
||||
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:
|
||||
await check_sumup_status(g.s, order)
|
||||
except Exception:
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.orm import selectinload
|
||||
|
||||
@@ -20,18 +20,6 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
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
|
||||
def route():
|
||||
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"]:
|
||||
return redirect(url_for("auth.login_form"))
|
||||
|
||||
@bp.get("/")
|
||||
async def list_orders():
|
||||
@bp.before_request
|
||||
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()
|
||||
if ident["user_id"]:
|
||||
owner_clause = Order.user_id == ident["user_id"]
|
||||
@@ -58,38 +153,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
where_clause = 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)
|
||||
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:
|
||||
@@ -116,38 +180,47 @@ def register(url_prefix: str) -> Blueprint:
|
||||
result = await g.s.execute(stmt)
|
||||
orders = result.scalars().all()
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import (
|
||||
render_orders_page,
|
||||
render_orders_rows,
|
||||
render_orders_oob,
|
||||
)
|
||||
from sx.sx_components import _orders_rows_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
ctx = await get_template_context()
|
||||
qs_fn = makeqs_factory()
|
||||
|
||||
if not is_htmx_request():
|
||||
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)
|
||||
|
||||
sx_src = _orders_rows_sx(orders, page, total_pages, url_for, qs_fn)
|
||||
resp = sx_response(sx_src)
|
||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||
return _vary(resp)
|
||||
|
||||
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)
|
||||
|
||||
@@ -12,10 +12,8 @@ from typing import Any
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
call_url, root_header_sx,
|
||||
full_page_sx, header_child_sx, oob_page_sx,
|
||||
call_url,
|
||||
sx_call, SxExpr,
|
||||
search_mobile_sx, search_desktop_sx,
|
||||
)
|
||||
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 = []
|
||||
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",
|
||||
oid=d["oid"], created=d["created"],
|
||||
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"]))
|
||||
|
||||
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",
|
||||
url=next_url, page=page,
|
||||
total_pages=total_pages,
|
||||
@@ -143,63 +141,8 @@ def _orders_summary_sx(ctx: dict) -> str:
|
||||
# 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
0
orders/sxc/__init__.py
Normal file
201
orders/sxc/pages/__init__.py
Normal file
201
orders/sxc/pages/__init__.py
Normal 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 "/"
|
||||
27
orders/sxc/pages/orders.sx
Normal file
27
orders/sxc/pages/orders.sx
Normal 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))
|
||||
@@ -33,7 +33,7 @@ from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -635,6 +635,85 @@ def _sf_defrelation(expr: list, env: dict) -> RelationDef:
|
||||
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] = {
|
||||
"if": _sf_if,
|
||||
"when": _sf_when,
|
||||
@@ -657,6 +736,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
|
||||
"defmacro": _sf_defmacro,
|
||||
"quasiquote": _sf_quasiquote,
|
||||
"defhandler": _sf_defhandler,
|
||||
"defpage": _sf_defpage,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -102,26 +102,136 @@ def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
)
|
||||
|
||||
|
||||
def mobile_nav_sx(ctx: dict) -> str:
|
||||
"""Build mobile navigation panel from context fragments (nav_tree, auth_menu)."""
|
||||
def mobile_menu_sx(*sections: str) -> str:
|
||||
"""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 ""
|
||||
auth_menu = ctx.get("auth_menu") or ""
|
||||
if not nav_tree and not auth_menu:
|
||||
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] = []
|
||||
if nav_tree:
|
||||
nav_tree_sx = _as_sx(nav_tree)
|
||||
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}/")
|
||||
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(
|
||||
f'(div :class "flex flex-col gap-2 p-3 text-sm" {nav_tree_sx})'
|
||||
)
|
||||
if auth_menu:
|
||||
auth_sx = _as_sx(auth_menu)
|
||||
parts.append(
|
||||
f'(div :class "p-3 border-t border-stone-200" {auth_sx})'
|
||||
f'(div :id "entries-calendars-nav-wrapper"'
|
||||
f' :class "flex flex-col sm:flex-row sm:items-center gap-2'
|
||||
f' 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:
|
||||
parts.append(admin_nav)
|
||||
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:
|
||||
"""Build mobile search input as sx call string."""
|
||||
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")
|
||||
|
||||
label_sx = sx_call("post-label", feature_image=feature_image, title=title)
|
||||
|
||||
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
|
||||
nav_sx = _post_nav_items_sx(ctx) or None
|
||||
link_href = call_url(ctx, "blog_url", f"/{slug}/")
|
||||
|
||||
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_sx = "(<> " + " ".join(label_parts) + ")"
|
||||
|
||||
# Nav items
|
||||
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
|
||||
nav_sx = _post_admin_nav_items_sx(ctx, slug, selected) or None
|
||||
|
||||
if not admin_href:
|
||||
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
|
||||
if not menu:
|
||||
menu = mobile_nav_sx(ctx)
|
||||
menu = mobile_root_nav_sx(ctx)
|
||||
body_sx = sx_call("app-body",
|
||||
header_rows=SxExpr(f"(<> {header_rows})") if header_rows else None,
|
||||
filter=SxExpr(filter) if filter else None,
|
||||
|
||||
@@ -16,6 +16,8 @@ from typing import Any, Callable, Awaitable
|
||||
from .helpers import (
|
||||
root_header_sx, post_header_sx, post_admin_header_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:
|
||||
"""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__(
|
||||
self,
|
||||
name: str,
|
||||
full_fn: Callable[..., str | Awaitable[str]],
|
||||
oob_fn: Callable[..., str | Awaitable[str]],
|
||||
mobile_fn: Callable[..., str | Awaitable[str]] | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self._full_fn = full_fn
|
||||
self._oob_fn = oob_fn
|
||||
self._mobile_fn = mobile_fn
|
||||
|
||||
async def full_headers(self, ctx: dict, **kwargs: Any) -> str:
|
||||
result = self._full_fn(ctx, **kwargs)
|
||||
@@ -50,6 +54,14 @@ class Layout:
|
||||
result = await 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:
|
||||
return f"<Layout:{self.name}>"
|
||||
|
||||
@@ -113,9 +125,27 @@ def _post_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
return "(<> " + post_hdr + " " + admin_oob + ")"
|
||||
|
||||
|
||||
register_layout(Layout("root", _root_full, _root_oob))
|
||||
register_layout(Layout("post", _post_full, _post_oob))
|
||||
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob))
|
||||
def _root_mobile(ctx: dict, **kw: Any) -> str:
|
||||
return mobile_root_nav_sx(ctx)
|
||||
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
Used by services with non-standard header patterns::
|
||||
|
||||
register_custom_layout("sx-section",
|
||||
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))
|
||||
|
||||
@@ -219,7 +219,7 @@ async def execute_page(
|
||||
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)
|
||||
|
||||
# Resolve layout → header rows
|
||||
# Resolve layout → header rows + mobile menu fallback
|
||||
tctx = await get_template_context()
|
||||
header_rows = ""
|
||||
oob_headers = ""
|
||||
@@ -261,6 +261,8 @@ async def execute_page(
|
||||
if layout is not None:
|
||||
header_rows = await layout.full_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
|
||||
is_htmx = is_htmx_request()
|
||||
@@ -288,17 +290,22 @@ async def execute_page(
|
||||
# Blueprint mounting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mount_pages(bp: Any, service_name: str) -> None:
|
||||
"""Mount all registered PageDef routes onto a Quart Blueprint.
|
||||
def mount_pages(bp: Any, service_name: str,
|
||||
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
|
||||
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
|
||||
|
||||
pages = get_all_pages(service_name)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -347,6 +354,9 @@ def _apply_auth(fn: Any, auth: str | list) -> Any:
|
||||
if auth == "admin":
|
||||
from shared.browser.app.authz import require_admin
|
||||
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":
|
||||
from shared.browser.app.authz import require_rights
|
||||
return require_rights(*auth[1:])(fn)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user