14 Commits

Author SHA1 Message Date
e085fe43b4 Replace sx_call() with render_to_sx() across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

- Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py
- Add (abort status msg) IO primitive in shared/sx/primitives_io.py
- Convert all 9 services: ~650 sx_call() invocations replaced
- Convert shared helpers (root_header_sx, full_page_sx, etc.) to async
- Fix likes service import bug (likes.models → models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:08:33 +00:00
0554f8a113 Refactor sx.js: extract string renderer, deduplicate helpers, remove dead code
Extract Node-only string renderer (renderToString, renderStr, etc.) to
sx-test.js. Add shared helpers (_processOOBSwaps, _postSwap, _processBindings,
_evalCond, _logParseError) replacing duplicated logic. Remove dead isTruthy
and _sxCssKnown class-list fallback. Compress section banners. sx.js goes
from 2652 to 2279 lines (-14%) with zero browser-side behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:00:58 +00:00
4e5f9ff16c Remove dead render_profile_page from federation sx_components
This function was replaced by defpage-based rendering but never deleted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:41:19 +00:00
193578ef88 Move SX construction from Python to .sx defcomps (phases 0-4)
Eliminate Python s-expression string building across account, orders,
federation, and cart services. Visual rendering logic now lives entirely
in .sx defcomp components; Python files contain only data serialization,
header/layout wiring, and thin wrappers that call defcomps.

Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/
pluralize/escape/route-prefix primitives.
Phase 1: Account — dashboard, newsletters, login/device/check-email content.
Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps.
Phase 3: Federation — social nav, post cards, timeline, search, actors,
notifications, compose, profile assembled defcomps.
Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin,
payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:36:34 +00:00
03f0929fdf Fix SX nav morphing, retry error modal, and aria-selected CSS extraction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m18s
- Re-read verb URL from element attributes at execution time so morphed
  nav links navigate to the correct destination
- Reset retry backoff on fresh requests; skip error modal when sx-retry
  handles the failure
- Strip attribute selectors in CSS registry so aria-selected:* classes
  resolve correctly for on-demand CSS
- Add @css annotations for dynamic aria-selected variant classes
- Add SX docs integration test suite (102 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:37:17 +00:00
f551fc7453 Convert last Python fragment handlers to SX defhandlers: 100% declarative fragment API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34m5s
- Add dict recursion to _convert_result for service methods returning dict[K, list[DTO]]
- New container-cards.sx: parses post_ids/slugs, calls confirmed-entries-for-posts, emits card-widget markers
- New account-page.sx: dispatches on slug for tickets/bookings panels with status pills and empty states
- Fix blog _parse_card_fragments to handle SxExpr via str() cast
- Remove events Python fragment handlers and simplify app.py to plain auto_mount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:42:19 +00:00
e30cb0a992 Auto-mount fragment handlers: eliminate fragment blueprint boilerplate across all 8 services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 16m38s
Fragment read API is now fully declarative — every handler is a defhandler
s-expression dispatched through one shared auto_mount_fragment_handlers()
function. Replaces 8 near-identical blueprint files (~35 lines each) with
a single function call per service. Events Python handlers (container-cards,
account-page) extracted to a standalone module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:13:15 +00:00
293f7713d6 Auto-mount defpages: eliminate Python route stubs across all 9 services
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 16s
Defpages are now declared with absolute paths in .sx files and auto-mounted
directly on the Quart app, removing ~850 lines of blueprint mount_pages calls,
before_request hooks, and g.* wrapper boilerplate. A new page = one defpage
declaration, nothing else.

Infrastructure:
- async_eval awaits coroutine results from callable dispatch
- auto_mount_pages() mounts all registered defpages on the app
- g._defpage_ctx pattern passes helper data to layout context

Migrated: sx, account, orders, federation, cart, market, events, blog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:03:15 +00:00
4ba63bda17 Add server-driven architecture principle and React feature analysis
Documents why sx stays server-driven by default, maps React features
to sx equivalents, and defines targeted escape hatches for the few
interactions that genuinely need client-side state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:48:35 +00:00
0a81a2af01 Convert social and federation profile from Jinja to SX rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 14m34s
Add primitives (replace, strip-tags, slice, csrf-token), convert all
social blueprint routes and federation profile to SX content builders,
delete 12 unused Jinja templates and social_lite layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:43:47 +00:00
0c9dbd6657 Add attribute detail pages with live demos for SX reference
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m45s
Per-attribute documentation pages at /reference/attributes/<slug> with:
- Live interactive demos (demo components in reference.sx)
- S-expression source code display
- Server handler code shown as s-expressions (defhandlers in handlers/reference.sx)
- Wire response display via OOB swaps on demo interaction
- Linked attribute names in the reference table

Covers all 20 implemented attributes (sx-get/post/put/delete/patch,
sx-trigger/target/swap/swap-oob/select/confirm/push-url/sync/encoding/
headers/include/vals/media/disable/on:*, sx-retry, data-sx, data-sx-env).

Also adds sx-on:* to BEHAVIOR_ATTRS, updates REFERENCE_NAV to link
/reference/attributes, and makes /reference/ an index page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:12:57 +00:00
a4377668be Add isomorphic SX architecture migration plan
Documents the 5-phase plan for making the sx s-expression layer a
universal view language that renders on either client or server, with
pages as cached components and data-only navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:52:12 +00:00
a98354c0f0 Fix duplicate headers on HTMX nav, editor content loading, and double mount
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m14s
- Nest admin header inside post-header-child (layouts.py/helpers.py) so
  full-page DOM matches OOB swap structure, eliminating duplicate headers
- Clear post-header-child on post layout OOB to remove stale admin rows
- Read SX initial content from #sx-content-input instead of
  window.__SX_INITIAL__ to avoid escaping issues through SX pipeline
- Fix client-side SX parser RE_STRING to handle escaped newlines
- Clear root element in SxEditor.mount() to prevent double content on
  HTMX re-mount
- Remove unused ~blog-editor-sx-initial component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:27:47 +00:00
df8b19ccb8 Convert post edit form from raw HTML to SX expressions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m29s
Replace _post_edit_content_sx raw HTML builder with sx_call() pattern
matching render_editor_panel. Add ~blog-editor-edit-form,
~blog-editor-publish-js, ~blog-editor-sx-initial components to
editor.sx. Fixes (~sx-editor-styles) rendering as literal text on
the edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:53:50 +00:00
153 changed files with 8059 additions and 6046 deletions

View File

@@ -8,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_account_bp, register_auth_bp, register_fragments
from bp import register_account_bp, register_auth_bp
async def account_context() -> dict:
@@ -81,11 +81,13 @@ def create_app() -> "Quart":
app.register_blueprint(register_auth_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 shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "account")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "account")
from bp.actions.routes import register as register_actions
app.register_blueprint(register_actions())

View File

@@ -1,3 +1,2 @@
from .account.routes import register as register_account_bp
from .auth.routes import register as register_auth_bp
from .fragments import register_fragments

View File

@@ -8,15 +8,12 @@ from __future__ import annotations
from quart import (
Blueprint,
request,
redirect,
g,
)
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.infrastructure.fragments import fetch_fragments
from shared.sx.helpers import sx_response
@@ -25,8 +22,7 @@ def register(url_prefix="/"):
@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)
"""Fetch account_nav fragments for layout."""
events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
@@ -34,48 +30,6 @@ def register(url_prefix="/"):
], required=False)
g.account_nav = events_nav + cart_nav + artdag_nav
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Newsletters page — load newsletter data
if endpoint.endswith("defpage_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,
)
)
user_subs = {un.newsletter_id: un for un in sub_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,
})
g.newsletters_data = newsletter_list
# 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):
if not g.get("user"):
@@ -102,6 +56,6 @@ def register(url_prefix="/"):
await g.s.flush()
from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un))
return sx_response(await render_newsletter_toggle(un))
return account_bp

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Account app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``account/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("account", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "account", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -27,3 +27,25 @@
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
;; Assembled auth page content — replaces Python _login_page_content etc.
(defcomp ~account-login-content (&key error email)
(~auth-login-form
:error (when error (~auth-error-banner :error error))
:action (url-for "auth.start_login")
:csrf-token (csrf-token)
:email (or email "")))
(defcomp ~account-device-content (&key error code)
(~account-device-form
:error (when error (~account-device-error :error error))
:action (url-for "auth.device_submit")
:csrf-token (csrf-token)
:code (or code "")))
(defcomp ~account-check-email-content (&key email email-error)
(~auth-check-email
:email (escape (or email ""))
:error (when email-error
(~auth-check-email-error :error (escape email-error)))))

View File

@@ -41,3 +41,20 @@
name)
logout)
labels)))
;; Assembled dashboard content — replaces Python _account_main_panel_sx
(defcomp ~account-dashboard-content (&key error)
(let* ((user (current-user))
(csrf (csrf-token)))
(~account-main-panel
:error (when error (~account-error-banner :error error))
:email (when (get user "email")
(~account-user-email :email (get user "email")))
:name (when (get user "name")
(~account-user-name :name (get user "name")))
:logout (~account-logout-form :csrf-token csrf)
:labels (when (not (empty? (or (get user "labels") (list))))
(~account-labels-section
:items (map (lambda (label)
(~account-label-item :name (get label "name")))
(get user "labels")))))))

View File

@@ -29,3 +29,34 @@
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
list)))
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
;; Takes pre-fetched newsletter-list from page helper
(defcomp ~account-newsletters-content (&key newsletter-list account-url)
(let* ((csrf (csrf-token)))
(if (empty? newsletter-list)
(~account-newsletter-empty)
(~account-newsletters-panel
:list (~account-newsletter-list
:items (map (lambda (item)
(let* ((nl (get item "newsletter"))
(un (get item "un"))
(nid (get nl "id"))
(subscribed (get item "subscribed"))
(toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/"))
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
(translate (if subscribed "translate-x-6" "translate-x-1"))
(checked (if subscribed "true" "false")))
(~account-newsletter-item
:name (get nl "name")
:desc (when (get nl "description")
(~account-newsletter-desc :description (get nl "description")))
:toggle (~account-newsletter-toggle
:id (str "nl-" nid)
:url toggle-url
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
:target (str "#nl-" nid)
:cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg)
:checked checked
:knob-cls (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate)))))
newsletter-list))))))

View File

@@ -1,8 +1,8 @@
"""
Account service s-expression page components.
Renders account dashboard, newsletters, fragment pages, login, and device
auth pages. Called from route handlers in place of ``render_template()``.
Renders login, device auth, and check-email pages. Dashboard and newsletters
are now fully handled by .sx defcomps called from defpage expressions.
"""
from __future__ import annotations
@@ -11,7 +11,7 @@ from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, sx_call, SxExpr,
render_to_sx,
root_header_sx, full_page_sx,
)
@@ -21,101 +21,72 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)),
# ---------------------------------------------------------------------------
# Header helpers
# Public API: Auth pages (login, device, check_email)
# ---------------------------------------------------------------------------
def _auth_nav_sx(ctx: dict) -> str:
"""Auth section desktop nav items."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
error = ctx.get("error", "")
email = ctx.get("email", "")
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-login-content",
error=error or None, email=email)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Login \u2014 Rose Ash</title>')
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
nav=SxExpr(_auth_nav_sx(ctx)),
child_id="auth-header-child", oob=oob,
)
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
error = ctx.get("error", "")
code = ctx.get("code", "")
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-device-content",
error=error or None, code=code)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
def _auth_nav_mobile_sx(ctx: dict) -> str:
"""Mobile nav menu for auth section."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-device-approved")
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
hdr = await root_header_sx(ctx)
content = await render_to_sx("account-check-email-content",
email=email, email_error=email_error)
return await full_page_sx(ctx, header_rows=hdr,
content=content,
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
def _account_main_panel_sx(ctx: dict) -> str:
"""Account info panel with user details and logout."""
from quart import g
async def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response."""
from shared.browser.app.csrf import generate_csrf_token
user = getattr(g, "user", None)
error = ctx.get("error", "")
error_sx = sx_call("account-error-banner", error=error) if error else ""
user_email_sx = ""
user_name_sx = ""
if user:
user_email_sx = sx_call("account-user-email", email=user.email)
if user.name:
user_name_sx = sx_call("account-user-name", name=user.name)
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
labels_sx = ""
if user and hasattr(user, "labels") and user.labels:
label_items = " ".join(
sx_call("account-label-item", name=label.name)
for label in user.labels
)
labels_sx = sx_call("account-labels-section",
items=SxExpr("(<> " + label_items + ")"))
return sx_call(
"account-main-panel",
error=SxExpr(error_sx) if error_sx else None,
email=SxExpr(user_email_sx) if user_email_sx else None,
name=SxExpr(user_name_sx) if user_name_sx else None,
logout=SxExpr(logout_sx),
labels=SxExpr(labels_sx) if labels_sx else None,
)
# ---------------------------------------------------------------------------
# Newsletters (GET /newsletters/)
# ---------------------------------------------------------------------------
def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
"""Render a single newsletter toggle switch."""
nid = un.newsletter_id
from quart import g
account_url_fn = getattr(g, "_account_url", None)
if account_url_fn is None:
from shared.infrastructure.urls import account_url
account_url_fn = account_url
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
if un.subscribed:
bg = "bg-emerald-500"
translate = "translate-x-6"
@@ -124,216 +95,13 @@ def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
return sx_call(
return await render_to_sx(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
)
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
return sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
target=f"#nl-{nid}",
cls="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300",
checked="false",
knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1",
)
def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str:
"""Newsletters management panel."""
from shared.browser.app.csrf import generate_csrf_token
account_url_fn = ctx.get("account_url") or (lambda p: p)
csrf = generate_csrf_token()
if newsletter_list:
items = []
for item in newsletter_list:
nl = item["newsletter"]
un = item.get("un")
desc_sx = sx_call(
"account-newsletter-desc", description=nl.description
) if nl.description else ""
if un:
toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
else:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf)
items.append(sx_call(
"account-newsletter-item",
name=nl.name,
desc=SxExpr(desc_sx) if desc_sx else None,
toggle=SxExpr(toggle),
))
list_sx = sx_call(
"account-newsletter-list",
items=SxExpr("(<> " + " ".join(items) + ")"),
)
else:
list_sx = sx_call("account-newsletter-empty")
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
# ---------------------------------------------------------------------------
# Auth pages (login, device, check_email)
# ---------------------------------------------------------------------------
def _login_page_content(ctx: dict) -> str:
"""Login form content."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
email = ctx.get("email", "")
action = url_for("auth.start_login")
error_sx = sx_call("auth-error-banner", error=error) if error else ""
return sx_call(
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), email=email,
)
def _device_page_content(ctx: dict) -> str:
"""Device authorization form content."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
code = ctx.get("code", "")
action = url_for("auth.device_submit")
error_sx = sx_call("account-device-error", error=error) if error else ""
return sx_call(
"account-device-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), code=code,
)
def _device_approved_content() -> str:
"""Device approved success content."""
return sx_call("account-device-approved")
# ---------------------------------------------------------------------------
# Public API: Account dashboard
# ---------------------------------------------------------------------------
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device)
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Check email page (POST /start/ success)
# ---------------------------------------------------------------------------
def _check_email_content(email: str, email_error: str | None = None) -> str:
"""Check email confirmation content."""
from markupsafe import escape
error_sx = sx_call(
"auth-check-email-error", error=str(escape(email_error))
) if email_error else ""
return sx_call(
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response (uses account_url)."""
from shared.browser.app.csrf import generate_csrf_token
from quart import g
account_url_fn = getattr(g, "_account_url", None)
if account_url_fn is None:
from shared.infrastructure.urls import account_url
account_url_fn = account_url
return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token())
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _sx_escape(s: str) -> str:
"""Escape a string for embedding in sx string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')

View File

@@ -26,30 +26,51 @@ def _register_account_layouts() -> None:
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
async def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
root_hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
root_hdr = await root_header_sx(ctx)
auth_hdr = await render_to_sx("auth-header-row",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
hdr_child = await header_child_sx(auth_hdr)
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
async def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
auth_hdr = await render_to_sx("auth-header-row",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
oob=True,
)
return "(<> " + auth_hdr + " " + await 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
async def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, render_to_sx
from shared.sx.parser import SxExpr
ctx = _inject_account_nav(ctx)
auth_section = sx_call("mobile-menu-section",
nav_items = await render_to_sx("auth-nav-items",
account_url=_call_url(ctx, "account_url", ""),
select_colours=ctx.get("select_colours", ""),
account_nav=_as_sx_nav(ctx),
)
auth_section = await render_to_sx("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))
items=SxExpr(nav_items))
return mobile_menu_sx(auth_section, await mobile_root_nav_sx(ctx))
def _call_url(ctx: dict, key: str, path: str = "/") -> str:
fn = ctx.get(key)
if callable(fn):
return fn(path)
return str(fn or "") + path
def _inject_account_nav(ctx: dict) -> dict:
@@ -61,6 +82,13 @@ def _inject_account_nav(ctx: dict) -> dict:
return ctx
def _as_sx_nav(ctx: dict) -> Any:
"""Convert account_nav fragment to SxExpr for use in component calls."""
from shared.sx.helpers import _as_sx
ctx = _inject_account_nav(ctx)
return _as_sx(ctx.get("account_nav"))
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
@@ -69,37 +97,69 @@ 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():
async def _h_newsletters_content(**kw):
"""Fetch newsletter data, return assembled defcomp call."""
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)
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.sx.helpers import render_to_sx
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,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": {"id": nl.id, "name": nl.name, "description": nl.description},
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
"subscribed": un.subscribed if un else False,
})
account_url = getattr(g, "_account_url", None)
if account_url is None:
from shared.infrastructure.urls import account_url as _account_url
account_url = _account_url
# Call account_url to get the base URL string
account_url_str = account_url("") if callable(account_url) else str(account_url or "")
return await render_to_sx("account-newsletters-content",
newsletter_list=newsletter_list,
account_url=account_url_str)
def _h_fragment_content():
from quart import g
frag = getattr(g, "fragment_page_data", None)
if not frag:
async def _h_fragment_content(slug=None, **kw):
from quart import g, abort
from shared.infrastructure.fragments import fetch_fragment
if not slug or not g.get("user"):
return ""
from sx.sx_components import _fragment_content
return _fragment_content(frag)
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.parser import SxExpr
if isinstance(fragment_html, SxExpr):
return fragment_html.source
s = str(fragment_html) if fragment_html else ""
if not s:
return ""
from shared.sx.helpers import render_to_sx
return await render_to_sx("rich-text", html=s)

View File

@@ -8,7 +8,7 @@
:path "/"
:auth :login
:layout :account
:content (account-content))
:content (~account-dashboard-content))
;; ---------------------------------------------------------------------------
;; Newsletters
@@ -28,4 +28,4 @@
:path "/<slug>/"
:auth :login
:layout :account
:content (fragment-content))
:content (fragment-content slug))

View File

@@ -16,7 +16,6 @@ from bp import (
register_admin,
register_menu_items,
register_snippets,
register_fragments,
register_data,
register_actions,
)
@@ -108,7 +107,9 @@ def create_app() -> "Quart":
app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "blog")
app.register_blueprint(register_data())
app.register_blueprint(register_actions())
@@ -162,6 +163,23 @@ def create_app() -> "Quart":
)
return jsonify(resp)
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "blog")
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_blog_data():
import os
from shared.config import config as get_config
ctx = {
"blog_title": get_config()["blog_title"],
"base_title": get_config()["title"],
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
ctx.update(getattr(g, '_defpage_ctx', {}))
return ctx
# --- debug: url rules ---
@app.get("/__rules")
async def dump_rules():

View File

@@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp
from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .fragments import register_fragments
from .data import register_data
from .actions.routes import register as register_actions

View File

@@ -3,13 +3,9 @@ from __future__ import annotations
#from quart import Blueprint, g
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
jsonify
)
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
@@ -27,23 +23,6 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings",
}
@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)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
@bp.post("/cache_clear/")
@require_admin
async def cache_clear():
@@ -54,7 +33,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.defpage_cache_page"))
return redirect(url_for("defpage_cache_page"))
return bp

View File

@@ -2,8 +2,6 @@ from __future__ import annotations
import re
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
@@ -13,9 +11,7 @@ from quart import (
from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from shared.sx.helpers import sx_response
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
@@ -46,60 +42,13 @@ async def _unassigned_tags(session):
def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@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)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
@bp.post("/")
@require_admin
async def create():
form = await request.form
name = (form.get("name") or "").strip()
if not name:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None
@@ -115,14 +64,14 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("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.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
form = await request.form
name = (form.get("name") or "").strip()
@@ -153,7 +102,7 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id))
return redirect(url_for("defpage_tag_group_edit", id=id))
@bp.post("/<int:id>/delete/")
@require_admin
@@ -163,6 +112,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.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
return bp

View File

@@ -53,16 +53,6 @@ def register(url_prefix, title):
@blogs_bp.before_request
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():
@@ -245,7 +235,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx)
return await make_response(html, 400)
@@ -254,7 +244,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason)
tctx["editor_html"] = await render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx)
return await make_response(html, 400)
@@ -277,7 +267,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog")
# Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)))
return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
@blogs_bp.post("/new-page/")
@@ -301,7 +291,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["is_page"] = True
html = await render_new_post_page(tctx)
return await make_response(html, 400)
@@ -311,7 +301,7 @@ def register(url_prefix, title):
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
tctx["is_page"] = True
html = await render_new_post_page(tctx)
return await make_response(html, 400)
@@ -335,7 +325,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.defpage_post_edit", slug=page.slug)))
return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/")

View File

@@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile(
def _parse_card_fragments(html: str) -> dict[str, str]:
"""Parse the container-cards fragment into {post_id_str: html} dict."""
result = {}
for m in _CARD_MARKER_RE.finditer(html):
for m in _CARD_MARKER_RE.finditer(str(html)):
post_id_str = m.group(1)
inner = m.group(2).strip()
if inner:

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Blog app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``blog/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("blog", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "blog", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -12,30 +12,15 @@ from .services.menu_items import (
search_pages,
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
def get_menu_items_nav_oob_sync(menu_items):
async def get_menu_items_nav_oob_async(menu_items):
"""Helper to generate OOB update for root nav menu items"""
from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(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 _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
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"])
return await render_menu_items_nav_oob(menu_items)
@bp.get("/new/")
@require_admin
@@ -66,8 +51,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
except MenuItemError as e:
@@ -106,8 +91,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
except MenuItemError as e:
@@ -127,8 +112,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
@bp.get("/pages/search/")
@@ -143,7 +128,7 @@ def register():
has_more = (page * per_page) < total
from sx.sx_components import render_page_search_results
return sx_response(render_page_search_results(pages, query, page, has_more))
return sx_response(await render_page_search_results(pages, query, page, has_more))
@bp.post("/reorder/")
@require_admin
@@ -168,8 +153,8 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
html = await render_menu_items_list(menu_items)
nav_oob = await get_menu_items_nav_oob_async(menu_items)
return sx_response(html + nav_oob)
return bp

View File

@@ -10,7 +10,6 @@ from quart import (
url_for,
)
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.utils import host_url
@@ -55,155 +54,6 @@ def _post_to_edit_dict(post) -> dict:
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@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)
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)
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)
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)
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
async def update_features(slug: str):
@@ -240,7 +90,7 @@ def register():
features = result.get("features", {})
from sx.sx_components import render_features_panel
html = render_features_panel(
html = await render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
@@ -279,7 +129,7 @@ def register():
features = result.get("features", {})
from sx.sx_components import render_features_panel
html = render_features_panel(
html = await render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
@@ -409,8 +259,8 @@ def register():
from sx.sx_components import render_associated_entries, render_nav_entries_oob
post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
admin_list = await render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = await render_nav_entries_oob(associated_entries, calendars, post)
return sx_response(admin_list + nav_entries_html)
@@ -468,7 +318,7 @@ def register():
except OptimisticLockError:
from urllib.parse import quote
return redirect(
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug))
host_url(url_for("defpage_post_settings", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -479,7 +329,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.defpage_post_settings", slug=post.slug)) + "?saved=1")
return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
@bp.post("/edit/")
@require_post_author
@@ -504,11 +354,11 @@ def register():
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
return redirect(host_url(url_for("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.defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -544,7 +394,7 @@ def register():
)
except OptimisticLockError:
return redirect(
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug))
host_url(url_for("defpage_post_edit", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -560,7 +410,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.defpage_post_edit", slug=post.slug)) + "?saved=1"
redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)
@@ -586,7 +436,7 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
return sx_response(await render_markets_panel(page_markets, post))
@bp.post("/markets/new/")
@require_admin
@@ -612,7 +462,7 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
return sx_response(await render_markets_panel(page_markets, post))
@bp.delete("/markets/<market_slug>/")
@require_admin
@@ -632,6 +482,6 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
return sx_response(await render_markets_panel(page_markets, post))
return bp

View File

@@ -125,7 +125,7 @@ def register():
# Get post_id from g.post_data
if not g.user:
return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
return sx_response(await render_like_toggle_button(slug, False, like_url), status=403)
post_id = g.post_data["post"]["id"]
user_id = g.user.id
@@ -135,7 +135,7 @@ def register():
})
liked = result["liked"]
return sx_response(render_like_toggle_button(slug, liked, like_url))
return sx_response(await render_like_toggle_button(slug, liked, like_url))
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -1,11 +1,9 @@
from __future__ import annotations
from quart import Blueprint, make_response, request, g, abort
from quart import Blueprint, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from models import Snippet
@@ -32,22 +30,6 @@ async def _visible_snippets(session):
def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@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 _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = snippets
tctx["is_admin"] = is_admin
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
async def delete_snippet(snippet_id: int):
@@ -65,7 +47,7 @@ def register():
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, is_admin))
return sx_response(await render_snippets_list(snippets, is_admin))
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
@@ -89,6 +71,6 @@ def register():
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, True))
return sx_response(await render_snippets_list(snippets, True))
return bp

View File

@@ -55,6 +55,104 @@
(button :type "submit"
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
;; Edit form — pre-populated version for /<slug>/admin/edit/
(defcomp ~blog-editor-edit-form (&key csrf updated-at title-val excerpt-val
feature-image feature-image-caption
sx-content-val lexical-json
has-sx title-placeholder
status already-emailed
newsletter-options footer-extra)
(let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600")
(active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent")
(inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"))
(form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "updated_at" :value updated-at)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val ""))
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image ""))
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption ""))
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
(div :id "feature-image-empty" :class (if feature-image "hidden" "")
(button :type "button" :id "feature-image-add-btn"
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
"+ Add feature image"))
(div :id "feature-image-filled" :class (str "relative " (if feature-image "" "hidden"))
(img :id "feature-image-preview" :src (or feature-image "") :alt ""
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
(button :type "button" :id "feature-image-delete-btn"
:class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
:title "Remove feature image"
(i :class "fa-solid fa-trash-can"))
(input :type "text" :id "feature-image-caption" :value (or feature-image-caption "")
:placeholder "Add a caption..."
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
(div :id "feature-image-uploading"
:class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"
(i :class "fa-solid fa-spinner fa-spin") " Uploading...")
(input :type "file" :id "feature-image-file"
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
(input :type "text" :name "title" :value (or title-val "") :placeholder title-placeholder
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
(or excerpt-val ""))
;; Editor tabs
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
(button :type "button" :id "editor-tab-sx"
:class (if has-sx active inactive)
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
"SX Editor")
(button :type "button" :id "editor-tab-koenig"
:class (if has-sx inactive active)
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
"Koenig (Legacy)"))
(div :id "sx-editor" :class "relative w-full bg-transparent"
:style (if has-sx "" "display:none"))
(div :id "lexical-editor" :class "relative w-full bg-transparent"
:style (if has-sx "display:none" ""))
;; Initial lexical JSON
(script :id "lexical-initial-data" :type "application/json" lexical-json)
;; Footer: status + publish mode + newsletter + save + badges
(div :class "flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
(select :id "status-select" :name "status" :class sel-cls
(option :value "draft" :selected (= status "draft") "Draft")
(option :value "published" :selected (= status "published") "Published"))
(select :id "publish-mode-select" :name "publish_mode"
:class (str sel-cls (if (= status "published") "" " hidden")
(if already-emailed " opacity-50 pointer-events-none" ""))
:disabled (if already-emailed true nil)
(option :value "web" :selected true "Web only")
(option :value "email" "Email only")
(option :value "both" "Web + Email"))
(select :id "newsletter-select" :name "newsletter_slug"
:class (str sel-cls " hidden")
:disabled (if already-emailed true nil)
newsletter-options)
(button :type "submit"
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
"Save")
(when footer-extra footer-extra)))))
;; Publish-mode show/hide script for edit form
(defcomp ~blog-editor-publish-js (&key already-emailed)
(script
"(function() {"
" var statusSel = document.getElementById('status-select');"
" var modeSel = document.getElementById('publish-mode-select');"
" var nlSel = document.getElementById('newsletter-select');"
(str " var alreadyEmailed = " (if already-emailed "true" "false") ";")
" function sync() {"
" var isPublished = statusSel.value === 'published';"
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
" var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');"
" if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }"
" }"
" statusSel.addEventListener('change', sync);"
" modeSel.addEventListener('change', sync);"
" sync();"
"})();"))
(defcomp ~blog-editor-styles (&key css-href)
(<> (link :rel "stylesheet" :href css-href)
(style

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,96 @@ def _load_blog_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "blog")
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_post_data(slug: str | None) -> None:
"""Load post data and set g.post_data + defpage context.
Replicates post bp's hydrate_post_data + context_processor.
"""
from quart import g, abort
if hasattr(g, 'post_data') and g.post_data:
await _inject_post_context(g.post_data)
return
if not slug:
abort(404)
from bp.post.services.post_data import post_data
is_admin = bool((g.get("rights") or {}).get("admin"))
p_data = await post_data(slug, g.s, include_drafts=True)
if not p_data:
abort(404)
# Draft access control
if p_data["post"].get("status") != "published":
if is_admin:
pass
elif g.user and p_data["post"].get("user_id") == g.user.id:
pass
else:
abort(404)
g.post_data = p_data
g.post_slug = slug
await _inject_post_context(p_data)
async def _inject_post_context(p_data: dict) -> None:
"""Add post context_processor data to defpage context."""
from shared.config import config
from shared.infrastructure.fragments import fetch_fragment
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.cart_identity import current_cart_identity
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
})
ctx: dict = {
**p_data,
"base_title": config()["title"],
"container_nav": container_nav,
}
if p_data["post"].get("is_page"):
ident = current_cart_identity()
summary_params: dict = {"page_slug": post_slug}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data(
"cart", "cart-summary", params=summary_params, required=False,
)
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
ctx["page_cart_count"] = (
page_summary.count + page_summary.calendar_count + page_summary.ticket_count
)
ctx["page_cart_total"] = float(
page_summary.total + page_summary.calendar_total + page_summary.ticket_total
)
_add_to_defpage_ctx(**ctx)
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
@@ -39,153 +129,153 @@ def _register_blog_layouts() -> None:
# --- Blog layout (root + blog header) ---
def _blog_full(ctx: dict, **kw: Any) -> str:
async 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)
root_hdr = await root_header_sx(ctx)
blog_hdr = await _blog_header_sx(ctx)
return "(<> " + root_hdr + " " + blog_hdr + ")"
def _blog_oob(ctx: dict, **kw: Any) -> str:
async 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)
root_hdr = await root_header_sx(ctx)
blog_hdr = await _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
return oob_header_sx("root-header-child", "blog-header-child", rows)
return await oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) ---
def _settings_full(ctx: dict, **kw: Any) -> str:
async 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)
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
return "(<> " + root_hdr + " " + settings_hdr + ")"
def _settings_oob(ctx: dict, **kw: Any) -> str:
async 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)
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
return await oob_header_sx("root-header-child", "root-settings-header-child", rows)
def _settings_mobile(ctx: dict, **kw: Any) -> str:
async def _settings_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _settings_nav_sx
return _settings_nav_sx(ctx)
return await _settings_nav_sx(ctx)
# --- Sub-settings helpers ---
def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
async 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,
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
sub_hdr = await _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,
async 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,
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = await _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)
sub_oob = await 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")
async def _cache_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "cache-row", "cache-header-child",
"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")
async def _cache_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"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")
async def _snippets_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"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")
async def _snippets_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"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")
async def _menu_items_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"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")
async def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"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")
async def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return await _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"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")
async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return await _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit ---
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
async 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),
root_hdr = await root_header_sx(ctx)
settings_hdr = await _settings_header_sx(ctx)
sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("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:
async 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),
settings_hdr_oob = await _settings_header_sx(ctx, oob=True)
sub_hdr = await _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("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)
sub_oob = await 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)
# Page helpers (async functions available in .sx defpage expressions)
# ---------------------------------------------------------------------------
def _register_blog_helpers() -> None:
@@ -208,71 +298,277 @@ def _register_blog_helpers() -> None:
})
def _h_editor_content():
# --- Editor helpers ---
async def _h_editor_content(**kw):
from sx.sx_components import render_editor_panel
return await render_editor_panel()
async def _h_editor_page_content(**kw):
from sx.sx_components import render_editor_panel
return await render_editor_panel(is_page=True)
# --- Post admin helpers ---
async def _h_post_admin_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g
return getattr(g, "editor_content", "")
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,
})
return _post_admin_main_panel_sx(tctx)
def _h_editor_page_content():
async def _h_post_data_content(slug=None, **kw):
await _ensure_post_data(slug)
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
return _post_data_content_sx(tctx)
async def _h_post_preview_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g
return getattr(g, "editor_page_content", "")
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: dict = {}
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)
return await _preview_main_panel_sx(tctx)
def _h_post_admin_content():
async def _h_post_entries_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g
return getattr(g, "post_admin_content", "")
from sqlalchemy import select
from shared.models.calendars import Calendar
from bp.post.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
return await _post_entries_content_sx(tctx)
def _h_post_data_content():
async def _h_post_settings_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.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
return _post_settings_content_sx(tctx)
async def _h_post_edit_content(slug=None, **kw):
await _ensure_post_data(slug)
from quart import g, request
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
from bp.post.admin.routes import _post_to_edit_dict
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
return await _post_edit_content_sx(tctx)
# --- Settings helpers ---
async def _h_settings_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
return _settings_main_panel_sx(tctx)
async def _h_cache_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
return await _cache_main_panel_sx(tctx)
# --- Snippets helper ---
async def _h_snippets_content(**kw):
from quart import g
return getattr(g, "post_data_content", "")
from sqlalchemy import select, or_
from models import Snippet
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await g.s.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = rows
tctx["is_admin"] = is_admin
return await _snippets_main_panel_sx(tctx)
def _h_post_preview_content():
# --- Menu Items helper ---
async def _h_menu_items_content(**kw):
from quart import g
return getattr(g, "post_preview_content", "")
from bp.menu_items.services.menu_items import get_all_menu_items
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
return await _menu_items_main_panel_sx(tctx)
def _h_post_entries_content():
# --- Tag Groups helpers ---
async def _h_tag_groups_content(**kw):
from quart import g
return getattr(g, "post_entries_content", "")
from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
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})
return await _tag_groups_main_panel_sx(tctx)
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", "")
async def _h_tag_group_edit_content(id=None, **kw):
from quart import g, abort
from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
tg = await g.s.get(TagGroup, id)
if not tg:
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == 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),
})
return await _tag_groups_edit_main_panel_sx(tctx)

View File

@@ -15,54 +15,54 @@
:layout :blog
:content (editor-page-content))
; --- Post admin pages (nested under /<slug>/admin/) ---
; --- Post admin pages (absolute paths under /<slug>/admin/) ---
(defpage post-admin
:path "/"
:path "/<slug>/admin/"
:auth :admin
:layout (:post-admin :selected "admin")
:content (post-admin-content))
:content (post-admin-content slug))
(defpage post-data
:path "/data/"
:path "/<slug>/admin/data/"
:auth :admin
:layout (:post-admin :selected "data")
:content (post-data-content))
:content (post-data-content slug))
(defpage post-preview
:path "/preview/"
:path "/<slug>/admin/preview/"
:auth :admin
:layout (:post-admin :selected "preview")
:content (post-preview-content))
:content (post-preview-content slug))
(defpage post-entries
:path "/entries/"
:path "/<slug>/admin/entries/"
:auth :admin
:layout (:post-admin :selected "entries")
:content (post-entries-content))
:content (post-entries-content slug))
(defpage post-settings
:path "/settings/"
:path "/<slug>/admin/settings/"
:auth :post_author
:layout (:post-admin :selected "settings")
:content (post-settings-content))
:content (post-settings-content slug))
(defpage post-edit
:path "/edit/"
:path "/<slug>/admin/edit/"
:auth :post_author
:layout (:post-admin :selected "edit")
:content (post-edit-content))
:content (post-edit-content slug))
; --- Settings pages ---
; --- Settings pages (absolute paths) ---
(defpage settings-home
:path "/"
:path "/settings/"
:auth :admin
:layout :blog-settings
:content (settings-content))
(defpage cache-page
:path "/cache/"
:path "/settings/cache/"
:auth :admin
:layout :blog-cache
:content (cache-content))
@@ -70,7 +70,7 @@
; --- Snippets ---
(defpage snippets-page
:path "/"
:path "/settings/snippets/"
:auth :login
:layout :blog-snippets
:content (snippets-content))
@@ -78,7 +78,7 @@
; --- Menu Items ---
(defpage menu-items-page
:path "/"
:path "/settings/menu_items/"
:auth :admin
:layout :blog-menu-items
:content (menu-items-content))
@@ -86,13 +86,13 @@
; --- Tag Groups ---
(defpage tag-groups-page
:path "/"
:path "/settings/tag-groups/"
:auth :admin
:layout :blog-tag-groups
:content (tag-groups-content))
(defpage tag-group-edit
:path "/<int:id>/"
:path "/settings/tag-groups/<int:id>/"
:auth :admin
:layout :blog-tag-group-edit
:content (tag-group-edit-content))
:content (tag-group-edit-content id))

View File

@@ -17,7 +17,6 @@ from bp import (
register_page_cart,
register_cart_global,
register_page_admin,
register_fragments,
register_actions,
register_data,
register_inbox,
@@ -141,7 +140,9 @@ def create_app() -> "Quart":
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "cart")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
app.register_blueprint(register_inbox())
@@ -185,8 +186,6 @@ def create_app() -> "Quart":
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
@@ -196,21 +195,22 @@ def create_app() -> "Quart":
url_prefix="/",
)
# Cart overview at GET /
# Cart overview blueprint (no defpage routes, just action endpoints)
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)
# Page admin (PUT /payments/ etc.)
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)
# Page cart (POST /checkout/ etc.)
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>")
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "cart")
return app

View File

@@ -2,7 +2,6 @@ from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global
from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data
from .inbox import register_inbox

View File

@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX
return redirect(url_for("cart_overview.defpage_cart_overview"))
return redirect(url_for("defpage_cart_overview"))
return redirect(url_for("cart_overview.defpage_cart_overview"))
return redirect(url_for("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.defpage_cart_overview"))
return redirect(url_for("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.defpage_cart_overview"))
return redirect(url_for("defpage_cart_overview"))
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)

View File

@@ -3,24 +3,9 @@
from __future__ import annotations
from quart import Blueprint, g, request
from .services import get_cart_grouped_by_page
from quart import Blueprint
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@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 _overview_main_panel_sx
page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
return bp

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@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)
from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
ctx = await get_template_context()
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():
post = g.page_post
@@ -48,7 +28,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.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
@@ -56,7 +36,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.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
ident = current_cart_identity()

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Cart app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``cart/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("cart", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "cart", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -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
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("page_admin", __name__)
@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
async def update_sumup(**kwargs):
@@ -66,7 +49,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_panel
ctx = await get_template_context()
html = render_cart_payments_panel(ctx)
html = await render_cart_payments_panel(ctx)
return sx_response(html)
return bp

View File

@@ -52,3 +52,114 @@
(div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
summary))))
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
(defcomp ~cart-item-from-data (&key item)
(let* ((slug (or (get item "slug") ""))
(title (or (get item "title") ""))
(image (get item "image"))
(brand (get item "brand"))
(is-deleted (get item "is_deleted"))
(unit-price (get item "unit_price"))
(special-price (get item "special_price"))
(regular-price (get item "regular_price"))
(currency (or (get item "currency") "GBP"))
(symbol (if (= currency "GBP") "\u00a3" currency))
(quantity (or (get item "quantity") 1))
(product-id (get item "product_id"))
(prod-url (or (get item "product_url") ""))
(qty-url (or (get item "qty_url") ""))
(csrf (csrf-token))
(line-total (when unit-price (* unit-price quantity))))
(~cart-item
:id (str "cart-item-" slug)
:img (if image
(~cart-item-img :src image :alt title)
(~img-or-placeholder :src nil
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
:placeholder-text "No image"))
:prod-url prod-url
:title title
:brand (when brand (~cart-item-brand :brand brand))
:deleted (when is-deleted (~cart-item-deleted))
:price (if unit-price
(<>
(~cart-item-price :text (str symbol (format-decimal unit-price 2)))
(when (and special-price (!= special-price regular-price))
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2)))))
(~cart-item-no-price))
:qty-url qty-url :csrf csrf
:minus (str (- quantity 1))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (when line-total
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
(defcomp ~cart-cal-section-from-data (&key entries)
(when (not (empty? entries))
(~cart-cal-section
:items (map (lambda (e)
(let* ((name (or (get e "name") ""))
(date-str (or (get e "date_str") "")))
(~cart-cal-entry
:name name :date-str date-str
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
entries))))
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
(defcomp ~cart-tickets-section-from-data (&key ticket-groups)
(when (not (empty? ticket-groups))
(let* ((csrf (csrf-token))
(qty-url (url-for "cart_global.update_ticket_quantity")))
(~cart-tickets-section
:items (map (lambda (tg)
(let* ((name (or (get tg "entry_name") ""))
(tt-name (get tg "ticket_type_name"))
(price (or (get tg "price") 0))
(quantity (or (get tg "quantity") 0))
(line-total (or (get tg "line_total") 0))
(entry-id (str (or (get tg "entry_id") "")))
(tt-id (get tg "ticket_type_id"))
(date-str (or (get tg "date_str") "")))
(~cart-ticket-article
:name name
:type-name (when tt-name (~cart-ticket-type-name :name tt-name))
:date-str date-str
:price (str "\u00a3" (format-decimal price 2))
:qty-url qty-url :csrf csrf
:entry-id entry-id
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id)))
:minus (str (max (- quantity 1) 0))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (str "Line total: \u00a3" (format-decimal line-total 2)))))
ticket-groups)))))
;; Assembled cart summary — replaces Python _cart_summary_sx
(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email)
(~cart-summary-panel
:item-count (str item-count)
:subtotal (str symbol (format-decimal grand-total 2))
:checkout (if is-logged-in
(~cart-checkout-form
:action checkout-action :csrf (csrf-token)
:label (str " Checkout as " user-email))
(~cart-checkout-signin :href login-href))))
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary)
(if (and (empty? (or cart-items (list)))
(empty? (or cal-entries (list)))
(empty? (or ticket-groups (list))))
(div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
(~cart-page-panel
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list)))
:cal (when (not (empty? (or cal-entries (list))))
(~cart-cal-section-from-data :entries cal-entries))
:tickets (when (not (empty? (or ticket-groups (list))))
(~cart-tickets-section-from-data :ticket-groups ticket-groups))
:summary summary)))

View File

@@ -39,3 +39,56 @@
(defcomp ~cart-overview-panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" cards)))
(defcomp ~cart-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
;; Assembled page group card — replaces Python _page_group_card_sx
(defcomp ~cart-page-group-card-from-data (&key grp cart-url-base)
(let* ((post (get grp "post"))
(product-count (or (get grp "product_count") 0))
(calendar-count (or (get grp "calendar_count") 0))
(ticket-count (or (get grp "ticket_count") 0))
(total (or (get grp "total") 0))
(market-place (get grp "market_place"))
(badges (<>
(when (> product-count 0)
(~cart-badge :icon "fa fa-box-open"
:text (str product-count " item" (pluralize product-count))))
(when (> calendar-count 0)
(~cart-badge :icon "fa fa-calendar"
:text (str calendar-count " booking" (pluralize calendar-count))))
(when (> ticket-count 0)
(~cart-badge :icon "fa fa-ticket"
:text (str ticket-count " ticket" (pluralize ticket-count)))))))
(if post
(let* ((slug (or (get post "slug") ""))
(title (or (get post "title") ""))
(feature-image (get post "feature_image"))
(mp-name (if market-place (or (get market-place "name") "") ""))
(display-title (if (!= mp-name "") mp-name title)))
(~cart-group-card
:href (str cart-url-base "/" slug "/")
:img (if feature-image
(~cart-group-card-img :src feature-image :alt title)
(~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
:placeholder-icon "fa fa-store text-xl"))
:display-title display-title
:subtitle (when (!= mp-name "")
(~cart-mp-subtitle :title title))
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))
(~cart-orphan-card
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))))
;; Assembled cart overview content — replaces Python _overview_main_panel_sx
(defcomp ~cart-overview-content (&key page-groups cart-url-base)
(if (empty? page-groups)
(~cart-empty)
(~cart-overview-panel
:cards (map (lambda (grp)
(~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base))
page-groups))))

View File

@@ -5,3 +5,27 @@
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :sx-select "#payments-panel")))
;; Assembled cart admin overview content
(defcomp ~cart-admin-content ()
(let* ((payments-href (url-for "defpage_cart_payments")))
(div :id "main-panel"
(div :class "flex items-center justify-between p-3 border-b"
(span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")
(a :href payments-href :class "text-sm underline" "configure")))))
;; Assembled cart payments content
(defcomp ~cart-payments-content (&key page-config)
(let* ((sumup-configured (and page-config (get page-config "sumup_api_key")))
(merchant-code (or (get page-config "sumup_merchant_code") ""))
(checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
(placeholder (if sumup-configured "--------" "sup_sk_..."))
(input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(~cart-payments-panel
:update-url (url-for "page_admin.update_sumup")
:csrf (csrf-token)
:merchant-code merchant-code
:placeholder placeholder
:input-cls input-cls
:sumup-configured sumup-configured
:checkout-prefix checkout-prefix)))

View File

@@ -1,8 +1,8 @@
"""
Cart service s-expression page components.
Renders cart overview, page cart, orders list, and single order detail.
Called from route handlers in place of ``render_template()``.
Thin Python wrappers for header/layout helpers and route-level render
functions. All visual rendering logic lives in .sx defcomps.
"""
from __future__ import annotations
@@ -16,9 +16,10 @@ from shared.sx.helpers import (
post_header_sx as _shared_post_header_sx,
search_desktop_sx, search_mobile_sx,
full_page_sx, oob_page_sx, header_child_sx,
sx_call, SxExpr,
render_to_sx,
)
from shared.infrastructure.urls import market_product_url, cart_url
from shared.sx.parser import SxExpr
from shared.infrastructure.urls import cart_url
# Load cart-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
@@ -26,7 +27,7 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)),
# ---------------------------------------------------------------------------
# Header helpers
# Header helpers (used by layouts in sxc/pages/__init__.py)
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
@@ -68,12 +69,12 @@ async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> st
"""Build post-level header row from page_post DTO, using shared helper."""
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return _shared_post_header_sx(ctx, oob=oob)
return await _shared_post_header_sx(ctx, oob=oob)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return sx_call(
return await render_to_sx(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
@@ -82,17 +83,17 @@ def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
)
def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build the per-page cart header row."""
slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160]
label_parts = []
if page_post and page_post.feature_image:
label_parts.append(sx_call("cart-page-label-img", src=page_post.feature_image))
label_parts.append(await render_to_sx("cart-page-label-img", src=page_post.feature_image))
label_parts.append(f'(span "{escape(title)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return sx_call(
nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return await render_to_sx(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
@@ -101,496 +102,77 @@ def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str
)
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
async def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders)."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
child_id="auth-header-child", oob=oob,
return await render_to_sx(
"auth-header-row-simple",
account_url=call_url(ctx, "account_url", ""),
oob=oob,
)
def _orders_header_sx(ctx: dict, list_url: str) -> str:
async def _orders_header_sx(ctx: dict, list_url: str) -> str:
"""Build the orders section header row."""
return sx_call(
"menu-row-sx",
id="orders-row", level=2, colour="sky",
link_href=list_url, link_label="Orders", icon="fa fa-gbp",
child_id="orders-header-child",
)
return await render_to_sx("orders-header-row", list_url=list_url)
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
"""Build the page-level admin header row."""
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------
# Cart overview
# Serialization helpers (shared with sxc/pages/__init__.py)
# ---------------------------------------------------------------------------
def _badge_sx(icon: str, count: int, label: str) -> str:
"""Render a count badge."""
s = "s" if count != 1 else ""
return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}")
def _page_group_card_sx(grp: Any, ctx: dict) -> str:
"""Render a single page group card for cart overview."""
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0)
calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0)
ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0)
total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
if not cart_items and not cal_entries and not tickets:
return ""
# Count badges
badge_parts = []
if product_count > 0:
badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item"))
if calendar_count > 0:
badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking"))
if ticket_count > 0:
badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket"))
badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""'
badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx))
if post:
slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
title = post.title if hasattr(post, "title") else post.get("title", "")
feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image")
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image:
img = sx_call("cart-group-card-img", src=feature_image, alt=title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="h-16 w-16 rounded-xl",
placeholder_icon="fa fa-store text-xl")
mp_sub = ""
if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = sx_call("cart-mp-subtitle", title=title)
else:
mp_name = ""
display_title = mp_name or title
return sx_call(
"cart-group-card",
href=cart_href, img=SxExpr(img), display_title=display_title,
subtitle=SxExpr(mp_sub) if mp_sub else None,
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
else:
# Orphan items
return sx_call(
"cart-orphan-card",
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
def _empty_cart_sx() -> str:
"""Empty cart state."""
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
f' {empty}))'
)
def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
return _empty_cart_sx()
cards = [_page_group_card_sx(grp, ctx) for grp in page_groups]
has_items = any(c for c in cards)
if not has_items:
return _empty_cart_sx()
cards_sx = "(<> " + " ".join(c for c in cards if c) + ")"
return sx_call("cart-overview-panel", cards=SxExpr(cards_sx))
# ---------------------------------------------------------------------------
# Page cart
# ---------------------------------------------------------------------------
def _cart_item_sx(item: Any, ctx: dict) -> str:
"""Render a single product cart item."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
p = item.product if hasattr(item, "product") else item
slug = p.slug if hasattr(p, "slug") else ""
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
symbol = "\u00a3" if currency == "GBP" else currency
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_quantity", product_id=p.id)
prod_url = market_product_url(slug)
if p.image:
img = sx_call("cart-item-img", src=p.image, alt=p.title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300",
placeholder_text="No image")
price_parts = []
if unit_price:
price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}"))
if p.special_price and p.special_price != p.regular_price:
price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}"))
else:
price_parts.append(sx_call("cart-item-no-price"))
price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0]
deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
line_total_sx = None
if unit_price:
lt = unit_price * item.quantity
line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
return sx_call(
"cart-item",
id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title,
brand=SxExpr(brand_sx) if brand_sx else None,
deleted=SxExpr(deleted_sx) if deleted_sx else None,
price=SxExpr(price_sx),
qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
qty=str(item.quantity), plus=str(item.quantity + 1),
line_total=SxExpr(line_total_sx) if line_total_sx else None,
)
def _calendar_entries_sx(entries: list) -> str:
"""Render calendar booking entries in cart."""
if not entries:
return ""
parts = []
for e in entries:
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else ""
parts.append(sx_call(
"cart-cal-entry",
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-cal-section", items=SxExpr(items_sx))
def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str:
"""Render ticket groups in cart."""
if not ticket_groups:
return ""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_ticket_quantity")
parts = []
for tg in ticket_groups:
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
tt_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None
tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None
parts.append(sx_call(
"cart-ticket-article",
name=name,
type_name=SxExpr(tt_name_sx) if tt_name_sx else None,
date_str=date_str,
price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
entry_id=str(entry_id),
type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None,
minus=str(max(quantity - 1, 0)), qty=str(quantity),
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-tickets-section", items=SxExpr(items_sx))
def _cart_summary_sx(ctx: dict, cart: list, cal_entries: list, tickets: list,
total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""Render the order summary sidebar."""
from shared.browser.app.csrf import generate_csrf_token
from quart import g, url_for, request
from shared.infrastructure.urls import login_url
csrf = generate_csrf_token()
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(tickets) if tickets else 0
item_count = product_qty + ticket_qty
product_total = total_fn(cart) or 0
cal_total = cal_total_fn(cal_entries) or 0
tk_total = ticket_total_fn(tickets) or 0
grand = float(product_total) + float(cal_total) + float(tk_total)
symbol = "\u00a3"
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
cur = cart[0].product.regular_price_currency
symbol = "\u00a3" if cur == "GBP" else cur
user = getattr(g, "user", None)
page_post = ctx.get("page_post")
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
from shared.utils import route_prefix
action = route_prefix() + action
checkout_sx = sx_call(
"cart-checkout-form",
action=action, csrf=csrf, label=f" Checkout as {user.email}",
)
else:
href = login_url(request.url)
checkout_sx = sx_call("cart-checkout-signin", href=href)
return sx_call(
"cart-summary-panel",
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
checkout=SxExpr(checkout_sx),
)
def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list,
tickets: list, ticket_groups: list,
total_fn: Any, cal_total_fn: Any,
ticket_total_fn: Any) -> str:
"""Page cart main panel."""
if not cart and not cal_entries and not tickets:
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :id "cart"'
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
f' {empty})))'
)
item_parts = [_cart_item_sx(item, ctx) for item in cart]
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""'
cal_sx = _calendar_entries_sx(cal_entries)
tickets_sx = _ticket_groups_sx(ticket_groups, ctx)
summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
return sx_call(
"cart-page-panel",
items=SxExpr(items_sx),
cal=SxExpr(cal_sx) if cal_sx else None,
tickets=SxExpr(tickets_sx) if tickets_sx else None,
summary=SxExpr(summary_sx),
)
# ---------------------------------------------------------------------------
# Orders list (same pattern as orders service)
# ---------------------------------------------------------------------------
def _order_row_sx(order: Any, detail_url: str) -> str:
"""Render a single order as desktop table row + mobile card."""
status = order.status or "pending"
sl = status.lower()
pill = (
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
else "border-stone-300 bg-stone-50 text-stone-700"
)
pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}"
def _serialize_order(order: Any) -> dict:
"""Serialize an order for SX defcomps."""
from shared.infrastructure.urls import market_product_url
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
desktop = sx_call(
"order-row-desktop",
oid=f"#{order.id}", created=created, desc=order.description or "",
total=total, pill=pill_cls, status=status, url=detail_url,
)
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
mobile = sx_call(
"order-row-mobile",
oid=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, url=detail_url,
)
return "(<> " + desktop + " " + mobile + ")"
items = []
if order.items:
for item in order.items:
items.append({
"product_image": item.product_image,
"product_title": item.product_title or "Unknown product",
"product_id": item.product_id,
"product_slug": item.product_slug,
"product_url": market_product_url(item.product_slug),
"quantity": item.quantity,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
"currency": item.currency or order.currency or "GBP",
})
return {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": created,
"description": order.description or "",
"total_formatted": f"{order.total_amount or 0:.2f}",
"total_amount": float(order.total_amount or 0),
"currency": order.currency or "GBP",
"items": items,
}
def _orders_rows_sx(orders: list, page: int, total_pages: int,
url_for_fn: Any, qs_fn: Any) -> str:
"""Render order rows + infinite scroll sentinel."""
from shared.utils import route_prefix
pfx = route_prefix()
parts = [
_order_row_sx(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
for o in orders
]
if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
parts.append(sx_call(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(sx_call("order-end-row"))
return "(<> " + " ".join(parts) + ")"
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
"""Main panel for orders list."""
if not orders:
return sx_call("order-empty-state")
return sx_call("order-table", rows=SxExpr(rows_sx))
def _orders_summary_sx(ctx: dict) -> str:
"""Filter section for orders list."""
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
def _serialize_calendar_entry(e: Any) -> dict:
"""Serialize an order calendar entry for SX defcomps."""
st = e.state or ""
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
return {
"name": e.name,
"state": st,
"date_str": ds,
"cost_formatted": f"{e.cost or 0:.2f}",
}
# ---------------------------------------------------------------------------
# Single order detail
# ---------------------------------------------------------------------------
def _order_items_sx(order: Any) -> str:
"""Render order items list."""
if not order or not order.items:
return ""
parts = []
for item in order.items:
prod_url = market_product_url(item.product_slug)
if item.product_image:
img = sx_call(
"order-item-image",
src=item.product_image, alt=item.product_title or "Product image",
)
else:
img = sx_call("order-item-no-image")
parts.append(sx_call(
"order-item-row",
href=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
pid=f"Product ID: {item.product_id}",
qty=f"Qty: {item.quantity}",
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-items-panel", items=SxExpr(items_sx))
def _order_summary_sx(order: Any) -> str:
"""Order summary card."""
return sx_call(
"order-summary-card",
order_id=order.id,
created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
description=order.description, status=order.status, currency=order.currency,
total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
)
def _order_calendar_items_sx(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order."""
if not calendar_entries:
return ""
parts = []
for e in calendar_entries:
st = e.state or ""
pill = (
"bg-emerald-100 text-emerald-800" if st == "confirmed"
else "bg-amber-100 text-amber-800" if st == "provisional"
else "bg-blue-100 text-blue-800" if st == "ordered"
else "bg-stone-100 text-stone-700"
)
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
parts.append(sx_call(
"order-calendar-entry",
name=e.name, pill=pill_cls, status=st.capitalize(),
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-calendar-section", items=SxExpr(items_sx))
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_sx(order)
items = _order_items_sx(order)
cal = _order_calendar_items_sx(calendar_entries)
return sx_call(
"order-detail-panel",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(cal) if cal else None,
)
def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
pay_url: str, csrf_token: str) -> str:
"""Filter section for single order detail."""
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
status = order.status or "pending"
pay_sx = None
if status != "paid":
pay_sx = sx_call("order-pay-btn", url=pay_url)
return sx_call(
"order-detail-filter",
info=f"Placed {created} \u00b7 Status: {status}",
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token,
pay=SxExpr(pay_sx) if pay_sx else None,
)
# ---------------------------------------------------------------------------
# Public API: Cart overview
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Public API: Orders list
# Public API: Orders list (used by cart/bp/orders/routes.py)
# ---------------------------------------------------------------------------
async def render_orders_page(ctx: dict, orders: list, page: int,
@@ -602,31 +184,62 @@ async def render_orders_page(ctx: dict, orders: list, page: int,
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
rows_url = list_url
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content",
orders=order_dicts,
page=page, total_pages=total_pages,
rows_url=rows_url, detail_url_prefix=detail_url_prefix)
hdr = root_header_sx(ctx)
auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
hdr = await root_header_sx(ctx)
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
auth_child = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await full_page_sx(ctx, header_rows=header_rows,
filter=filt,
aside=await search_desktop_sx(ctx),
content=content)
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."""
return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
from shared.utils import route_prefix
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
parts = []
for od in order_dicts:
parts.append(await render_to_sx("order-row-pair",
order=od,
detail_url_prefix=detail_url_prefix))
if page < total_pages:
next_url = list_url + qs_fn(page=page + 1)
parts.append(await render_to_sx(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(await render_to_sx("order-end-row"))
return "(<> " + " ".join(parts) + ")"
async def render_orders_oob(ctx: dict, orders: list, page: int,
@@ -638,28 +251,36 @@ async def render_orders_oob(ctx: dict, orders: list, page: int,
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
rows_url = list_url
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content",
orders=order_dicts,
page=page, total_pages=total_pages,
rows_url=rows_url, detail_url_prefix=detail_url_prefix)
auth_oob = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call(
auth_oob = await _auth_header_sx(ctx, oob=True)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_oob = await render_to_sx(
"oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)),
row=SxExpr(orders_hdr),
)
root_oob = root_header_sx(ctx, oob=True)
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await oob_page_sx(oobs=oobs,
filter=filt,
aside=await search_desktop_sx(ctx),
content=content)
# ---------------------------------------------------------------------------
# Public API: Single order detail
# Public API: Single order detail (used by cart/bp/order/routes.py)
# ---------------------------------------------------------------------------
async def render_order_page(ctx: dict, order: Any,
@@ -675,24 +296,35 @@ async def render_order_page(ctx: dict, order: Any,
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_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
hdr = root_header_sx(ctx)
order_row = sx_call(
main = await render_to_sx("order-detail-content",
order=order_data,
calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content",
order=order_data,
list_url=list_url, recheck_url=recheck_url,
pay_url=pay_url, csrf=generate_csrf_token())
hdr = await root_header_sx(ctx)
order_row = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
order_child = sx_call(
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
order_child = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + _auth_header_sx(ctx) + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(
"(<> " + _orders_header_sx(ctx, list_url) + " " + sx_call("header-child-sx", id="orders-header-child", inner=SxExpr(order_row)) + ")"
)) + ")"),
inner=SxExpr("(<> " + auth + " " + auth_child + ")"),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx: dict, order: Any,
@@ -708,100 +340,69 @@ async def render_order_oob(ctx: dict, order: Any,
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_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
order_row_oob = sx_call(
main = await render_to_sx("order-detail-content",
order=order_data,
calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content",
order=order_data,
list_url=list_url, recheck_url=recheck_url,
pay_url=pay_url, csrf=generate_csrf_token())
order_row_oob = await render_to_sx(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
)
orders_child_oob = sx_call("oob-header-sx",
orders_child_oob = await render_to_sx("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_oob = root_header_sx(ctx, oob=True)
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs, filter=filt, content=main)
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error
# Public API: Checkout error (used by cart/bp/cart routes + order routes)
# ---------------------------------------------------------------------------
def _checkout_error_filter_sx() -> str:
return sx_call("checkout-error-header")
def _checkout_error_content_sx(error: str | None, order: Any | None) -> str:
async def render_checkout_error_page(ctx: dict, error: str | None = None,
order: Any | None = None) -> str:
"""Full page: checkout error."""
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = None
if order:
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
return sx_call(
hdr = await root_header_sx(ctx)
filt = await render_to_sx("checkout-error-header")
content = await render_to_sx(
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=back_url,
)
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error."""
hdr = root_header_sx(ctx)
filt = _checkout_error_filter_sx()
content = _checkout_error_content_sx(error, order)
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# Page admin (/<page_slug>/admin/)
# Public API: POST response renderers
# ---------------------------------------------------------------------------
def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
"""Build the page-level admin header row -- delegates to shared helper."""
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
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.defpage_cart_payments")
return (
'(div :id "main-panel"'
' (div :class "flex items-center justify-between p-3 border-b"'
' (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")'
f' (a :href "{payments_href}" :class "text-sm underline" "configure")))'
)
def _cart_payments_main_panel_sx(ctx: dict) -> str:
"""Render SumUp payment config form."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
page_config = ctx.get("page_config")
sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None))
merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else ""
checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else ""
update_url = url_for("page_admin.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
return sx_call("cart-payments-panel",
update_url=update_url, csrf=csrf,
merchant_code=merchant_code, placeholder=placeholder,
input_cls=input_cls, sumup_configured=sumup_configured,
checkout_prefix=checkout_prefix)
def render_cart_payments_panel(ctx: dict) -> str:
async def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _cart_payments_main_panel_sx(ctx)
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return await render_to_sx("cart-payments-content",
page_config=pc_data)

View File

@@ -27,31 +27,35 @@ def _register_cart_layouts() -> None:
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
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import 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(
root_hdr = await root_header_sx(ctx)
child = await _cart_header_sx(ctx)
page_hdr = await _page_cart_header_sx(ctx, page_post)
inner_child = await render_to_sx("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr))
nested = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
inner=SxExpr("(<> " + child + " " + inner_child + ")"),
)
return "(<> " + root_hdr + " " + nested + ")"
def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import 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",
page_hdr = await _page_cart_header_sx(ctx, page_post)
child_oob = await render_to_sx("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)
row=SxExpr(page_hdr))
cart_hdr_oob = await _cart_header_sx(ctx, oob=True)
root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
@@ -61,9 +65,9 @@ async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
root_hdr = await 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)
admin_hdr = await _cart_page_admin_header_sx(ctx, page_post, selected=selected)
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
@@ -72,7 +76,7 @@ async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
# ---------------------------------------------------------------------------
@@ -90,32 +94,224 @@ def _register_cart_helpers() -> None:
})
def _h_overview_content():
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _serialize_cart_item(item: Any) -> dict:
"""Serialize a cart item + product for SX defcomps."""
from quart import url_for
from shared.infrastructure.urls import market_product_url
p = item.product if hasattr(item, "product") else item
slug = p.slug if hasattr(p, "slug") else ""
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
return {
"slug": slug,
"title": p.title if hasattr(p, "title") else "",
"image": p.image if hasattr(p, "image") else None,
"brand": getattr(p, "brand", None),
"is_deleted": getattr(item, "is_deleted", False),
"unit_price": float(unit_price) if unit_price else None,
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
"currency": currency,
"quantity": item.quantity,
"product_id": p.id,
"product_url": market_product_url(slug),
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
}
def _serialize_cal_entry(e: Any) -> dict:
"""Serialize a calendar entry for SX defcomps."""
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else ""
return {
"name": name,
"date_str": f"{start}{end_str}",
"cost": float(cost),
}
def _serialize_ticket_group(tg: Any) -> dict:
"""Serialize a ticket group for SX defcomps."""
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
return {
"entry_name": name,
"ticket_type_name": tt_name or None,
"price": float(price or 0),
"quantity": quantity,
"line_total": float(line_total or 0),
"entry_id": entry_id,
"ticket_type_id": tt_id or None,
"date_str": date_str,
}
def _serialize_page_group(grp: Any) -> dict:
"""Serialize a page group for SX defcomps."""
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
if not cart_items and not cal_entries and not tickets:
return None
post_data = None
if post:
post_data = {
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
"title": post.title if hasattr(post, "title") else post.get("title", ""),
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
}
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
mp_data = None
if market_place:
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
return {
"post": post_data,
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
"market_place": mp_data,
}
def _build_summary_data(ctx: dict, cart: list, cal_entries: list, tickets: list,
total_fn, cal_total_fn, ticket_total_fn) -> dict:
"""Build cart summary data dict for SX defcomps."""
from quart import g, request, url_for
from shared.infrastructure.urls import login_url
from shared.utils import route_prefix
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(tickets) if tickets else 0
item_count = product_qty + ticket_qty
product_total = total_fn(cart) or 0
cal_total = cal_total_fn(cal_entries) or 0
tk_total = ticket_total_fn(tickets) or 0
grand = float(product_total) + float(cal_total) + float(tk_total)
symbol = "\u00a3"
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
cur = cart[0].product.regular_price_currency
symbol = "\u00a3" if cur == "GBP" else cur
user = getattr(g, "user", None)
page_post = ctx.get("page_post")
result = {
"item_count": item_count,
"grand_total": grand,
"symbol": symbol,
"is_logged_in": bool(user),
}
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
result["checkout_action"] = route_prefix() + action
result["user_email"] = user.email
else:
result["login_href"] = login_url(request.url)
return result
# ---------------------------------------------------------------------------
# Page helper implementations
# ---------------------------------------------------------------------------
async def _h_overview_content(**kw):
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.helpers import render_to_sx
from shared.infrastructure.urls import cart_url
from bp.cart.services import get_cart_grouped_by_page
page_groups = await get_cart_grouped_by_page(g.s)
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
return await render_to_sx("cart-overview-content",
page_groups=grp_dicts,
cart_url_base=cart_url(""))
async def _h_page_cart_content(page_slug=None, **kw):
from quart import g
from shared.sx.helpers import render_to_sx
from shared.sx.parser import SxExpr
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", "")
from bp.cart.services import total, calendar_total, ticket_total
from bp.cart.services.page_cart import (
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
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)
ctx = await get_template_context()
sd = _build_summary_data(ctx, cart, cal_entries, page_tickets,
total, calendar_total, ticket_total)
summary_sx = await render_to_sx("cart-summary-from-data",
item_count=sd["item_count"],
grand_total=sd["grand_total"],
symbol=sd["symbol"],
is_logged_in=sd["is_logged_in"],
checkout_action=sd.get("checkout_action"),
login_href=sd.get("login_href"),
user_email=sd.get("user_email"))
return await render_to_sx("cart-page-cart-content",
cart_items=[_serialize_cart_item(i) for i in cart],
cal_entries=[_serialize_cal_entry(e) for e in cal_entries],
ticket_groups=[_serialize_ticket_group(tg) for tg in ticket_groups],
summary=SxExpr(summary_sx))
def _h_page_cart_content():
from quart import g
return getattr(g, "page_cart_content", "")
async def _h_cart_admin_content(page_slug=None, **kw):
return '(~cart-admin-content)'
def _h_cart_admin_content():
from sx.sx_components import _cart_admin_main_panel_sx
async def _h_cart_payments_content(page_slug=None, **kw):
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", "")
from shared.sx.helpers import render_to_sx
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")
ctx = await get_template_context()
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return await render_to_sx("cart-payments-content",
page_config=pc_data)

View File

@@ -7,19 +7,19 @@
:content (overview-content))
(defpage page-cart-view
:path "/"
:path "/<page_slug>/"
:auth :public
:layout :cart-page
:content (page-cart-content))
(defpage cart-admin
:path "/"
:path "/<page_slug>/admin/"
:auth :admin
:layout :cart-admin
:content (cart-admin-content))
(defpage cart-payments
:path "/payments/"
:path "/<page_slug>/admin/payments/"
:auth :admin
:layout (:cart-admin :selected "payments")
:content (cart-payments-content))

View File

@@ -358,3 +358,89 @@ Each service migrates independently, no coordination needed:
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
---
## Why: Architectural Rationale
The end state is: **sx.js is the only JavaScript in the browser.** All application code — components, pages, routing, event handling, data fetching — is expressed in sx, evaluated by the interpreter, with behavior mediated through bound primitives.
### Benefits
**Single language everywhere.** Components, pages, routing, event handling, data fetching — all sx. No context-switching between JS idioms and template syntax. One language for the entire frontend and the server rendering path.
**Portability.** The same source runs on any VM that implements the ~50-primitive interface. Today: Python + JS. Tomorrow: WASM, edge workers, native mobile, embedded devices. Coupled to a primitive contract, not to a specific runtime.
**Smaller wire transfer.** S-expressions are terser than equivalent JS. Combined with content-addressed caching (hash/localStorage), most navigations transfer zero code — just data.
**Inspectability.** The sx source is the running program — no build step, no source maps, no minification. View source shows exactly what executes. The AST is the structure the evaluator walks. Debugging is tracing a tree.
**Controlled surface area.** The only JS that runs is sx.js. Everything else is mediated through defined primitives. No npm supply chain. No third-party scripts with ambient DOM access. Components can only do what primitives allow — the capability surface is fully controlled.
**Hot-reloadable everything.** Components are data (cached AST). Swapping a definition is replacing a dict entry. No module system, no import graph, no HMR machinery. Already works for .sx file changes in dev mode — extends to behaviors too.
**AI-friendly.** S-expressions are trivially parseable and generatable. An LLM produces correct sx far more reliably than JS/JSX — fewer syntax edge cases, no semicolons/braces/arrow-function ambiguities. The codebase becomes more amenable to automated generation and transformation.
**Security boundary.** No `eval()`, no dynamic `<script>` injection, no prototype pollution. The sx evaluator is a sandbox — it only resolves symbols against the primitive table and component env. Auditing what any sx expression can do means auditing the primitive bindings.
### Performance and WASM
The tradeoff is interpreter overhead — a tree-walking interpreter is slower than native JS execution. For UI rendering (building DOM, handling events, fetching data), this is not the bottleneck — DOM operations dominate, and those are the same speed regardless of initiator.
If performance ever becomes a concern, WASM is the escape hatch at three levels:
1. **Evaluator in WASM.** Rewrite `sxEval` in Rust/Zig → WASM. The tight inner loop (symbol lookup, env traversal, function application) runs ~10-50x faster. DOM rendering stays in JS (it calls browser APIs regardless).
2. **Compile sx to WASM.** Ahead-of-time compiler: `.sx` → WASM modules. Each `defcomp` becomes a WASM function returning DOM instructions. Eliminates the interpreter entirely. The content-addressed cache stores compiled WASM blobs instead of sx source.
3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change.
The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change.
### Server-Driven by Default: The React Question
The sx system is architecturally aligned with HTMX/LiveView — server-driven UI — even though it does far more on the client (full s-expression evaluation, DOM rendering, morph reconciliation, component caching). The server is the single source of truth. Every UI state is a URL. Auth is enforced at render time. There are no state synchronization bugs because there is no client state to synchronize.
React's client-state model (`useState`, `useEffect`, Context, Suspense) exists because React was built for SPAs that need to feel like native apps — optimistic updates, offline capability, instant feedback without network latency. But it created an entire category of problems: state management libraries, hydration mismatches, cache invalidation, stale closures, memory leaks from forgotten cleanup, the `useEffect` footgun.
**The question is not "should sx have useState" — it's which specific interactions actually suffer from the server round-trip.**
For most of our apps, that's a very short list:
- Toggle a mobile nav panel
- Gallery image switching
- Quantity steppers
- Live search-as-you-type
These don't need a general-purpose reactive state system. They need **targeted client-side primitives** that handle those specific cases without abandoning the server-driven model.
**The dangerous path:** Add `useState` → need `useEffect` for cleanup → need Context to avoid prop drilling → need Suspense for async state → rebuild React inside sx → lose the simplicity that makes the server-driven model work.
**The careful path:** Keep server-driven as the default. Add explicit, targeted escape hatches for interactions that genuinely need client-side state. Make those escape hatches obviously different from the normal flow so they don't creep into everything.
#### What sx has vs React
| React feature | SX status | Verdict |
|---|---|---|
| Components + props | `defcomp` + `&key` | Done — cleaner than JSX |
| Fragments, conditionals, lists | `<>`, `if`/`when`/`cond`, `map` | Done — more expressive |
| Macros | `defmacro` | Done — React has nothing like this |
| OOB updates / portals | `sx-swap-oob` | Done — more powerful (server-driven) |
| DOM reconciliation | `_morphDOM` (id-keyed) | Done — works during SxEngine swaps |
| Reactive client state | None | **By design.** Server is source of truth. |
| Component lifecycle | None | Add targeted primitives if body.js behaviors move to sx |
| Context / providers | `_componentEnv` global | Sufficient for auth/theme; revisit if trees get deep |
| Suspense / loading | `sx-request` CSS class | Sufficient for server-driven; revisit for Phase 4 client data |
| Two-way data binding | None | Not needed — HTMX model (form POST → new HTML) works |
| Error boundaries | Global `sx:responseError` | Sufficient; per-component boundaries are a future nice-to-have |
| Keyed list reconciliation | id-based morph | Works; add `:key` prop support if list update bugs arise |
#### Targeted escape hatches (not a general state system)
For the few interactions that need client-side responsiveness, add **specific primitives** rather than a general framework:
- `(toggle! el "class")` — CSS class toggle, no server trip
- `(set-attr! el "attr" value)` — attribute manipulation
- `(on-event el "click" handler)` — declarative event binding within sx
- `(timer interval-ms handler)` — with automatic cleanup on DOM removal
These are imperative DOM operations exposed as primitives — not reactive state. They let components handle simple client-side interactions without importing React's entire mental model. The server-driven flow remains the default for anything involving data.

View File

@@ -9,7 +9,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_actions, register_data
async def events_context() -> dict:
@@ -112,7 +112,9 @@ def create_app() -> "Quart":
url_prefix="/<slug>/markets",
)
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "events")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
@@ -171,19 +173,25 @@ def create_app() -> "Quart":
"markets": markets,
}
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "events")
# Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as 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
ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
app.register_blueprint(ticket_admin_bp)
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_events_data():
return getattr(g, '_defpage_ctx', {})
# --- oEmbed endpoint ---
@app.get("/oembed")
async def oembed():

View File

@@ -3,6 +3,5 @@ from .calendar.routes import register as register_calendar
from .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets
from .page.routes import register as register_page
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

@@ -11,7 +11,7 @@ Routes:
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
@@ -126,7 +126,7 @@ def register() -> Blueprint:
frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust")
widget_html = await render_ticket_widget(entry, qty, "/all-tickets/adjust")
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(widget_html + (mini_html or ""))

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
Blueprint, g, request,
)
@@ -15,23 +15,11 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@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 _calendar_admin_main_panel_html
ctx = await get_template_context()
g.calendar_admin_content = _calendar_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["calendar-admin"])
@bp.get("/description/")
@require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description_edit
html = render_calendar_description_edit(g.calendar)
html = await render_calendar_description_edit(g.calendar)
return sx_response(html)
@@ -47,7 +35,7 @@ def register():
await g.s.flush()
from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar, oob=True)
html = await render_calendar_description(g.calendar, oob=True)
return sx_response(html)
@@ -55,7 +43,7 @@ def register():
@require_admin
async def calendar_description_view(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar)
html = await render_calendar_description(g.calendar)
return sx_response(html)
return bp

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession
request, make_response, Blueprint, g, abort, session as qsession
)
@@ -201,7 +201,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
html = _calendar_admin_main_panel_html(ctx)
html = await _calendar_admin_main_panel_html(ctx)
return sx_response(html)
@@ -220,7 +220,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel
ctx = await get_template_context()
html = render_calendars_list_panel(ctx)
html = await render_calendars_list_panel(ctx)
if post_data:
from shared.services.entry_associations import get_associated_entries
@@ -236,7 +236,7 @@ def register():
).scalars().all()
associated_entries = await get_associated_entries(post_id)
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob
return sx_response(html)

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from decimal import Decimal
from quart import (
request, render_template, make_response,
request, make_response,
Blueprint, g, redirect, url_for, jsonify,
)
@@ -259,7 +259,7 @@ def register():
}
from sx.sx_components import render_day_main_panel
html = render_day_main_panel(ctx)
html = await render_day_main_panel(ctx)
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(html + (mini_html or ""))
@@ -280,12 +280,12 @@ def register():
day_slots = list(result.scalars())
from sx.sx_components import render_entry_add_form
return sx_response(render_entry_add_form(g.calendar, day, month, year, day_slots))
return sx_response(await render_entry_add_form(g.calendar, day, month, year, day_slots))
@bp.get("/add-button/")
async def add_button(day: int, month: int, year: int, **kwargs):
from sx.sx_components import render_entry_add_button
return sx_response(render_entry_add_button(g.calendar, day, month, year))
return sx_response(await render_entry_add_button(g.calendar, day, month, year))

View File

@@ -1,23 +1,8 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
)
from quart import Blueprint
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@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 _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"])
return bp

View File

@@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select
from quart import (
request, render_template, make_response, Blueprint, g, jsonify
request, make_response, Blueprint, g, jsonify
)
from ..calendar_entries.services.entries import (
svc_update_entry,
@@ -112,7 +112,7 @@ def register():
# Render OOB nav
from sx.sx_components import render_day_entries_nav_oob
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
return await render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
async def get_post_nav_oob(entry_id: int):
"""Helper to generate OOB update for post entries nav when entry state changes"""
@@ -149,7 +149,7 @@ def register():
# Render OOB nav for this post
from sx.sx_components import render_post_nav_entries_oob
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post)
nav_oob = await render_post_nav_entries_oob(associated_entries, calendars, post)
nav_oobs.append(nav_oob)
return "".join(nav_oobs)
@@ -238,19 +238,6 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav": container_nav,
}
@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 _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)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-detail"])
@bp.get("/edit/")
@require_admin
async def get_edit(entry_id: int, **rest):
@@ -270,7 +257,7 @@ def register():
day_slots = list(result.scalars())
from sx.sx_components import render_entry_edit_form
return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
return sx_response(await render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
@bp.put("/")
@require_admin
@@ -436,7 +423,7 @@ def register():
from sx.sx_components import _entry_main_panel_html
tctx = await get_template_context()
html = _entry_main_panel_html(tctx)
html = await _entry_main_panel_html(tctx)
return sx_response(html + nav_oob)
@@ -462,7 +449,7 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/decline/")
@@ -487,7 +474,7 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/provisional/")
@@ -512,7 +499,7 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
html = await render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/tickets/")
@@ -556,7 +543,7 @@ def register():
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
await g.s.refresh(g.entry)
from sx.sx_components import render_entry_tickets_config
html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
html = await render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
return sx_response(html)
@bp.get("/posts/search/")
@@ -572,7 +559,7 @@ def register():
va = request.view_args or {}
from sx.sx_components import render_post_search_results
return sx_response(render_post_search_results(
return sx_response(await render_post_search_results(
search_posts, query, page, total_pages,
g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
@@ -607,8 +594,8 @@ def register():
# Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts)
html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = await render_entry_posts_nav_oob(entry_posts)
return sx_response(html + nav_oob)
@bp.delete("/posts/<int:post_id>/")
@@ -629,8 +616,8 @@ def register():
# Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts)
html = await render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = await render_entry_posts_nav_oob(entry_posts)
return sx_response(html + nav_oob)
return bp

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
request, make_response, Blueprint, g
)
from sqlalchemy import select
@@ -69,7 +69,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel
ctx = await get_template_context()
html = render_calendars_list_panel(ctx)
html = await render_calendars_list_panel(ctx)
# Blog-embedded mode: also update post nav
if post_data:
@@ -85,7 +85,7 @@ def register():
).scalars().all()
associated_entries = await get_associated_entries(post_id)
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
nav_oob = await render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob
return sx_response(html)

View File

@@ -1,21 +1,8 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
)
from quart import Blueprint
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@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"])
return bp

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone, date, timedelta
from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession
request, make_response, Blueprint, g, abort, session as qsession
)
from bp.calendar.services import get_visible_entries_for_period

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,104 +0,0 @@
"""Events app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``events/sx/handlers/`` and dispatched via the sx handler registry.
container-cards and account-page remain as Python handlers because they
call domain service methods and return batched/conditional content, but
they use sx_call() for rendering (no Jinja templates).
"""
from __future__ import annotations
from quart import Blueprint, Response, g, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
# Fragment types that return HTML (comment-delimited batch)
_html_types = {"container-cards"}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
# 1. Check Python handlers first
handler = _handlers.get(fragment_type)
if handler is not None:
result = await handler()
ct = "text/html" if fragment_type in _html_types else "text/sx"
return Response(result, status=200, content_type=ct)
# 2. Check sx handler registry
handler_def = get_handler("events", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "events", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
# --- container-cards fragment: entries for blog listing cards -----------
# Returns text/html with <!-- card-widget:POST_ID --> comment markers
# so the blog consumer can split per-post fragments.
async def _container_cards_handler():
from sx.sx_components import render_fragment_container_cards
post_ids_raw = request.args.get("post_ids", "")
post_slugs_raw = request.args.get("post_slugs", "")
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()]
if not post_ids:
return ""
slug_map = {}
for i, pid in enumerate(post_ids):
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
return render_fragment_container_cards(batch, post_ids, slug_map)
_handlers["container-cards"] = _container_cards_handler
# --- account-page fragment: tickets or bookings panel ------------------
# Returns text/sx — the account app embeds this as sx source.
async def _account_page_handler():
from sx.sx_components import (
render_fragment_account_tickets,
render_fragment_account_bookings,
)
slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int)
if not user_id:
return ""
if slug == "tickets":
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
return render_fragment_account_tickets(tickets)
elif slug == "bookings":
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
return render_fragment_account_bookings(bookings)
return ""
_handlers["account-page"] = _account_page_handler
bp._fragment_handlers = _handlers
return bp

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
request, make_response, Blueprint, g
)
from .services.markets import (
@@ -21,18 +21,6 @@ def register():
async def inject_root():
return {}
@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 _markets_main_panel_html
ctx = await get_template_context()
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
async def create_market(**kwargs):
@@ -56,7 +44,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel
ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx))
return sx_response(await render_markets_list_panel(ctx))
@bp.delete("/<market_slug>/")
@require_admin
@@ -69,6 +57,6 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel
ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx))
return sx_response(await render_markets_list_panel(ctx))
return bp

View File

@@ -8,7 +8,7 @@ Routes:
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
@@ -107,7 +107,7 @@ def register() -> Blueprint:
frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
widget_html = await render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(widget_html + (mini_html or ""))

View File

@@ -29,27 +29,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("slot", __name__, url_prefix='/<int: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:
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)
@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
async def get_edit(slot_id: int, **kwargs):
@@ -57,7 +36,7 @@ def register():
if not slot:
return await make_response("Not found", 404)
from sx.sx_components import render_slot_edit_form
return sx_response(render_slot_edit_form(slot, g.calendar))
return sx_response(await render_slot_edit_form(slot, g.calendar))
@bp.get("/view/")
@require_admin
@@ -66,7 +45,7 @@ def register():
if not slot:
return await make_response("Not found", 404)
from sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar))
return sx_response(await render_slot_main_panel(slot, g.calendar))
@bp.delete("/")
@require_admin
@@ -75,7 +54,7 @@ def register():
await svc_delete_slot(g.s, slot_id)
slots = await svc_list_slots(g.s, g.calendar.id)
from sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar))
return sx_response(await render_slots_table(slots, g.calendar))
@bp.put("/")
@require_admin
@@ -157,7 +136,7 @@ def register():
), 422
from sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar, oob=True))
return sx_response(await render_slot_main_panel(slot, g.calendar, oob=True))

View File

@@ -38,18 +38,6 @@ def register():
}
return {"slots": []}
@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
@clear_cache(tag="calendars", tag_scope="all")
@@ -123,19 +111,19 @@ def register():
# Success → re-render the slots table
slots = await svc_list_slots(g.s, g.calendar.id)
from sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar))
return sx_response(await render_slots_table(slots, g.calendar))
@bp.get("/add")
@require_admin
async def add_form(**kwargs):
from sx.sx_components import render_slot_add_form
return sx_response(render_slot_add_form(g.calendar))
return sx_response(await render_slot_add_form(g.calendar))
@bp.get("/add-button")
@require_admin
async def add_button(**kwargs):
from sx.sx_components import render_slot_add_button
return sx_response(render_slot_add_button(g.calendar))
return sx_response(await render_slot_add_button(g.calendar))
return bp

View File

@@ -14,10 +14,10 @@ import logging
from quart import (
Blueprint, g, request, make_response,
)
from sqlalchemy import select, func
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType
from models.calendars import CalendarEntry
from shared.browser.app.authz import require_admin
from shared.browser.app.redis_cacher import clear_cache
from shared.sx.helpers import sx_response
@@ -34,46 +34,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
@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)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
# Stats
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
g.ticket_admin_content = _ticket_admin_main_panel_html(ctx, tickets, stats)
@bp.get("/entry/<int:entry_id>/")
@require_admin
async def entry_tickets(entry_id: int):
@@ -94,7 +54,7 @@ def register() -> Blueprint:
tickets = await get_tickets_for_entry(g.s, entry_id)
from sx.sx_components import render_entry_tickets_admin
html = render_entry_tickets_admin(entry, tickets)
html = await render_entry_tickets_admin(entry, tickets)
return sx_response(html)
@bp.get("/lookup/")
@@ -111,9 +71,9 @@ def register() -> Blueprint:
ticket = await get_ticket_by_code(g.s, code)
from sx.sx_components import render_lookup_result
if not ticket:
return sx_response(render_lookup_result(None, "Ticket not found"))
return sx_response(await render_lookup_result(None, "Ticket not found"))
return sx_response(render_lookup_result(ticket, None))
return sx_response(await render_lookup_result(ticket, None))
@bp.post("/<code>/checkin/")
@require_admin
@@ -124,9 +84,9 @@ def register() -> Blueprint:
from sx.sx_components import render_checkin_result
if not success:
return sx_response(render_checkin_result(False, error, None))
return sx_response(await render_checkin_result(False, error, None))
ticket = await get_ticket_by_code(g.s, code)
return sx_response(render_checkin_result(True, None, ticket))
return sx_response(await render_checkin_result(True, None, ticket))
return bp

View File

@@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int: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:
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"),
)
@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
async def get_edit(ticket_type_id: int, **kwargs):
@@ -58,7 +32,7 @@ def register():
from sx.sx_components import render_ticket_type_edit_form
va = request.view_args or {}
return sx_response(render_ticket_type_edit_form(
return sx_response(await render_ticket_type_edit_form(
ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -73,7 +47,7 @@ def register():
from sx.sx_components import render_ticket_type_main_panel
va = request.view_args or {}
return sx_response(render_ticket_type_main_panel(
return sx_response(await render_ticket_type_main_panel(
ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -140,7 +114,7 @@ def register():
# Return updated view with OOB flag
from sx.sx_components import render_ticket_type_main_panel
va = request.view_args or {}
return sx_response(render_ticket_type_main_panel(
return sx_response(await render_ticket_type_main_panel(
ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
oob=True,
@@ -159,7 +133,7 @@ def register():
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
from sx.sx_components import render_ticket_types_table
va = request.view_args or {}
return sx_response(render_ticket_types_table(
return sx_response(await render_ticket_types_table(
ticket_types, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))

View File

@@ -35,23 +35,6 @@ def register():
}
return {"ticket_types": []}
@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"),
)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-types-listing"])
@bp.post("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
@@ -112,7 +95,7 @@ def register():
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
from sx.sx_components import render_ticket_types_table
va = request.view_args or {}
return sx_response(render_ticket_types_table(
return sx_response(await render_ticket_types_table(
ticket_types, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -123,7 +106,7 @@ def register():
"""Show the add ticket type form."""
from sx.sx_components import render_ticket_type_add_form
va = request.view_args or {}
return sx_response(render_ticket_type_add_form(
return sx_response(await render_ticket_type_add_form(
g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -134,7 +117,7 @@ def register():
"""Show the add ticket type button."""
from sx.sx_components import render_ticket_type_add_button
va = request.view_args or {}
return sx_response(render_ticket_type_add_button(
return sx_response(await render_ticket_type_add_button(
g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))

View File

@@ -24,8 +24,6 @@ from shared.sx.helpers import sx_response
from .services.tickets import (
create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count,
get_tickets_for_entry,
get_sold_ticket_count,
@@ -39,44 +37,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@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")
async def buy_tickets():
@@ -167,7 +127,7 @@ def register() -> Blueprint:
cart_count = summary.count + summary.calendar_count + summary.ticket_count
from sx.sx_components import render_buy_result
return sx_response(render_buy_result(entry, created, remaining, cart_count))
return sx_response(await render_buy_result(entry, created, remaining, cart_count))
@bp.post("/adjust/")
@clear_cache(tag="calendars", tag_scope="all")
@@ -290,7 +250,7 @@ def register() -> Blueprint:
cart_count = summary.count + summary.calendar_count + summary.ticket_count
from sx.sx_components import render_adjust_response
return sx_response(render_adjust_response(
return sx_response(await render_adjust_response(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type, cart_count,
))

View File

@@ -0,0 +1,49 @@
;; Account-page fragment handler
;;
;; Renders tickets or bookings panel for the account dashboard.
;; slug=tickets → ticket list; slug=bookings → booking list.
(defhandler account-page (&key slug user_id)
(let ((uid (parse-int (or user_id "0"))))
(when (> uid 0)
(cond
(= slug "tickets")
(let ((tickets (service "calendar" "user-tickets" :user-id uid)))
(~events-frag-tickets-panel
:items (if (empty? tickets)
(~empty-state :message "No tickets yet."
:cls "text-sm text-stone-500")
(~events-frag-tickets-list
:items (<> (map (fn (t)
(~events-frag-ticket-item
:href (app-url "events"
(str "/tickets/" (get t "code") "/"))
:entry-name (get t "entry_name")
:date-str (format-date (get t "entry_start_at") "%d %b %Y, %H:%M")
:calendar-name (when (get t "calendar_name")
(span (str "\u00b7 " (get t "calendar_name"))))
:type-name (when (get t "ticket_type_name")
(span (str "\u00b7 " (get t "ticket_type_name"))))
:badge (~status-pill :status (or (get t "state") ""))))
tickets))))))
(= slug "bookings")
(let ((bookings (service "calendar" "user-bookings" :user-id uid)))
(~events-frag-bookings-panel
:items (if (empty? bookings)
(~empty-state :message "No bookings yet."
:cls "text-sm text-stone-500")
(~events-frag-bookings-list
:items (<> (map (fn (b)
(~events-frag-booking-item
:name (get b "name")
:date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M")
(if (get b "end_at")
(str " \u2013 " (format-date (get b "end_at") "%H:%M"))
""))
:calendar-name (when (get b "calendar_name")
(span (str "\u00b7 " (get b "calendar_name"))))
:cost-str (when (get b "cost")
(span (str "\u00b7 \u00a3" (get b "cost"))))
:badge (~status-pill :status (or (get b "state") ""))))
bookings))))))))))

View File

@@ -0,0 +1,38 @@
;; Container-cards fragment handler
;;
;; Returns HTML with <!-- card-widget:ID --> comment markers so the
;; blog consumer can split per-post fragments. Each post section
;; contains an events-frag-entries-widget with entry cards.
(defhandler container-cards (&key post_ids post_slugs)
(let ((ids (filter (fn (x) (> x 0))
(map parse-int
(filter (fn (s) (not (empty? s)))
(split (or post_ids "") ",")))))
(slugs (map trim
(split (or post_slugs "") ","))))
(when (not (empty? ids))
(let ((batch (service "calendar" "confirmed-entries-for-posts" :post-ids ids)))
(<> (map-indexed (fn (i pid)
(let ((entries (or (get batch pid) (list)))
(post-slug (or (nth slugs i) "")))
(<> (str "<!-- card-widget:" pid " -->")
(when (not (empty? entries))
(~events-frag-entries-widget
:cards (<> (map (fn (e)
(let ((time-str (str (format-date (get e "start_at") "%H:%M")
(if (get e "end_at")
(str " \u2013 " (format-date (get e "end_at") "%H:%M"))
""))))
(~events-frag-entry-card
:href (app-url "events"
(str "/" post-slug
"/" (get e "calendar_slug")
"/" (get e "start_at_year")
"/" (get e "start_at_month")
"/" (get e "start_at_day")
"/entries/" (get e "id") "/"))
:name (get e "name")
:date-str (format-date (get e "start_at") "%a, %b %d")
:time-str time-str))) entries))))
(str "<!-- /card-widget:" pid " -->")))) ids))))))

File diff suppressed because it is too large Load Diff

View File

@@ -44,11 +44,11 @@ async def _cal_admin_full(ctx: dict, **kw: Any) -> str:
)
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)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = admin_hdr + await _calendar_header_sx(ctx) + await _calendar_admin_header_sx(ctx)
return root_hdr + post_hdr + await header_child_sx(child)
async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -59,10 +59,10 @@ async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
)
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 = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _calendar_header_sx(ctx, oob=True))
oobs += await oob_header_sx("calendar-header-child", "calendar-admin-header-child",
await _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",
@@ -83,8 +83,8 @@ async def _slots_oob(ctx: dict, **kw: Any) -> str:
)
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 = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _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",
@@ -102,12 +102,12 @@ async def _slot_full(ctx: dict, **kw: Any) -> str:
)
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)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + await _calendar_header_sx(ctx)
+ await _calendar_admin_header_sx(ctx) + await _slot_header_html(ctx))
return root_hdr + post_hdr + await header_child_sx(child)
async def _slot_oob(ctx: dict, **kw: Any) -> str:
@@ -118,10 +118,10 @@ async def _slot_oob(ctx: dict, **kw: Any) -> str:
)
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 = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _calendar_admin_header_sx(ctx, oob=True))
oobs += await oob_header_sx("calendar-admin-header-child", "slot-header-child",
await _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",
@@ -140,12 +140,12 @@ async def _day_admin_full(ctx: dict, **kw: Any) -> str:
)
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)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
+ await _day_admin_header_sx(ctx))
return root_hdr + post_hdr + await header_child_sx(child)
async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -156,10 +156,10 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
)
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 = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _calendar_header_sx(ctx, oob=True))
oobs += await oob_header_sx("day-header-child", "day-admin-header-child",
await _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",
@@ -170,26 +170,26 @@ async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
# --- Entry layout (root + child(post + cal + day + entry), + menu) ---
def _entry_full(ctx: dict, **kw: Any) -> str:
async 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)
root_hdr = await root_header_sx(ctx)
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ await _day_header_sx(ctx) + await _entry_header_html(ctx))
return root_hdr + await header_child_sx(child)
def _entry_oob(ctx: dict, **kw: Any) -> str:
async 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 = await _day_header_sx(ctx, oob=True)
oobs += await oob_header_sx("day-header-child", "entry-header-child",
await _entry_header_html(ctx))
oobs += _clear_deeper_oob("post-row", "post-header-child",
"calendar-row", "calendar-header-child",
"day-row", "day-header-child",
@@ -208,12 +208,12 @@ async def _entry_admin_full(ctx: dict, **kw: Any) -> str:
)
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)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
child = (admin_hdr + await _calendar_header_sx(ctx) + await _day_header_sx(ctx)
+ await _entry_header_html(ctx) + await _entry_admin_header_html(ctx))
return root_hdr + post_hdr + await header_child_sx(child)
async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
@@ -224,10 +224,10 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
)
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 = (await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")
+ await _entry_header_html(ctx, oob=True))
oobs += await oob_header_sx("entry-header-child", "entry-admin-header-child",
await _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",
@@ -239,78 +239,255 @@ async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
# --- Ticket types layout (extends entry admin with ticket-types header, + menu) ---
def _ticket_types_full(ctx: dict, **kw: Any) -> str:
async 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)
root_hdr = await root_header_sx(ctx)
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ await _day_header_sx(ctx) + await _entry_header_html(ctx)
+ await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx))
return root_hdr + await header_child_sx(child)
def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
async 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))
oobs = await _entry_admin_header_html(ctx, oob=True)
oobs += await oob_header_sx("entry-admin-header-child", "ticket_types-header-child",
await _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:
async 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)
root_hdr = await root_header_sx(ctx)
child = (await _post_header_sx(ctx) + await _calendar_header_sx(ctx)
+ await _day_header_sx(ctx) + await _entry_header_html(ctx)
+ await _entry_admin_header_html(ctx) + await _ticket_types_header_html(ctx)
+ await _ticket_type_header_html(ctx))
return root_hdr + await header_child_sx(child)
def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
async 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))
oobs = await _ticket_types_header_html(ctx, oob=True)
oobs += await oob_header_sx("ticket_types-header-child", "ticket_type-header-child",
await _ticket_type_header_html(ctx))
return oobs
# --- Markets layout (root + child(post + markets)) ---
def _markets_full(ctx: dict, **kw: Any) -> str:
async 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)
root_hdr = await root_header_sx(ctx)
child = await _post_header_sx(ctx) + await _markets_header_sx(ctx)
return root_hdr + await header_child_sx(child)
def _markets_oob(ctx: dict, **kw: Any) -> str:
async 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))
oobs = await _post_header_sx(ctx, oob=True)
oobs += await oob_header_sx("post-header-child", "markets-header-child",
await _markets_header_sx(ctx))
return oobs
# ---------------------------------------------------------------------------
# Shared hydration helpers
# ---------------------------------------------------------------------------
def _add_to_defpage_ctx(**kwargs: Any) -> None:
"""Add data to g._defpage_ctx for the app-level context_processor."""
from quart import g
if not hasattr(g, '_defpage_ctx'):
g._defpage_ctx = {}
g._defpage_ctx.update(kwargs)
async def _ensure_calendar(calendar_slug: str | None) -> None:
"""Load calendar into g.calendar if not already present."""
from quart import g, abort
if hasattr(g, 'calendar'):
_add_to_defpage_ctx(calendar=g.calendar)
return
from bp.calendar.services.calendar_view import (
get_calendar_by_post_and_slug, get_calendar_by_slug,
)
post_data = getattr(g, "post_data", None)
if post_data:
post_id = (post_data.get("post") or {}).get("id")
cal = await get_calendar_by_post_and_slug(g.s, post_id, calendar_slug)
else:
cal = await get_calendar_by_slug(g.s, calendar_slug)
if not cal:
abort(404)
g.calendar = cal
g.calendar_slug = calendar_slug
_add_to_defpage_ctx(calendar=cal)
async def _ensure_entry(entry_id: int | None) -> None:
"""Load calendar entry into g.entry if not already present."""
from quart import g, abort
if hasattr(g, 'entry'):
_add_to_defpage_ctx(entry=g.entry)
return
from sqlalchemy import select
from models.calendars import CalendarEntry
result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
entry = result.scalar_one_or_none()
if entry is None:
abort(404)
g.entry = entry
_add_to_defpage_ctx(entry=entry)
async def _ensure_entry_context(entry_id: int | None) -> None:
"""Load full entry context (ticket data, posts) into g.* and _defpage_ctx."""
from quart import g
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from bp.tickets.services.tickets import (
get_available_ticket_count,
get_sold_ticket_count,
get_user_reserved_count,
)
from shared.infrastructure.cart_identity import current_cart_identity
from bp.calendar_entry.services.post_associations import get_entry_posts
await _ensure_entry(entry_id)
# Reload with ticket_types eagerly loaded
stmt = (
select(CalendarEntry)
.where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None))
.options(selectinload(CalendarEntry.ticket_types))
)
result = await g.s.execute(stmt)
calendar_entry = result.scalar_one_or_none()
if calendar_entry and getattr(g, "calendar", None):
if calendar_entry.calendar_id != g.calendar.id:
calendar_entry = None
if calendar_entry:
await g.s.refresh(calendar_entry, ['slot'])
g.entry = calendar_entry
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
ident = current_cart_identity()
user_ticket_count = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
user_ticket_counts_by_type = {}
if calendar_entry.ticket_types:
for tt in calendar_entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
_add_to_defpage_ctx(
entry=calendar_entry,
entry_posts=entry_posts,
ticket_remaining=ticket_remaining,
ticket_sold_count=ticket_sold_count,
user_ticket_count=user_ticket_count,
user_ticket_counts_by_type=user_ticket_counts_by_type,
)
async def _ensure_day_data(year: int, month: int, day: int) -> None:
"""Load day-specific data for layout header functions."""
from quart import g, session as qsession
if hasattr(g, 'day_date'):
return
from datetime import date as date_cls, datetime, timezone, timedelta
from sqlalchemy import select
from bp.calendar.services import get_visible_entries_for_period
from models.calendars import CalendarSlot
calendar = getattr(g, "calendar", None)
if not calendar:
return
try:
day_date = date_cls(year, month, day)
except (ValueError, TypeError):
return
period_start = datetime(year, month, day, tzinfo=timezone.utc)
period_end = period_start + timedelta(days=1)
user = getattr(g, "user", None)
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar.id,
period_start=period_start,
period_end=period_end,
user=user,
session_id=session_id,
)
weekday_attr = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][day_date.weekday()]
stmt = (
select(CalendarSlot)
.where(
CalendarSlot.calendar_id == calendar.id,
getattr(CalendarSlot, weekday_attr) == True, # noqa: E712
CalendarSlot.deleted_at.is_(None),
)
.order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc())
)
result = await g.s.execute(stmt)
day_slots = list(result.scalars())
g.day_date = day_date
_add_to_defpage_ctx(
qsession=qsession,
day_date=day_date,
day=day,
year=year,
month=month,
day_entries=visible.merged_entries,
user_entries=visible.user_entries,
confirmed_entries=visible.confirmed_entries,
day_slots=day_slots,
)
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
@@ -336,71 +513,191 @@ def _register_events_helpers() -> None:
})
def _h_calendar_admin_content():
async def _h_calendar_admin_content(calendar_slug=None, **kw):
await _ensure_calendar(calendar_slug)
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
return await _calendar_admin_main_panel_html(ctx)
async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
await _ensure_calendar(calendar_slug)
if year is not None:
await _ensure_day_data(int(year), int(month), int(day))
from sx.sx_components import _day_admin_main_panel_html
return await _day_admin_main_panel_html({})
async def _h_slots_content(calendar_slug=None, **kw):
from quart import g
return getattr(g, "calendar_admin_content", "")
await _ensure_calendar(calendar_slug)
calendar = getattr(g, "calendar", None)
from bp.slots.services.slots import list_slots as svc_list_slots
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
from sx.sx_components import render_slots_table
return await render_slots_table(slots, calendar)
def _h_day_admin_content():
async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
return await render_slot_main_panel(slot, calendar)
async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html
ctx = await get_template_context()
return await _entry_main_panel_html(ctx)
async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_nav_html
ctx = await get_template_context()
return await _entry_nav_html(ctx)
async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
return await _entry_admin_main_panel_html(ctx)
async def _h_admin_menu():
from shared.sx.helpers import render_to_sx
return await render_to_sx("events-admin-placeholder-nav")
async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
year=None, month=None, day=None, **kw):
from quart import g
return getattr(g, "day_admin_content", "")
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types)
from sx.sx_components import render_ticket_types_table
return await render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
def _h_slots_content():
async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
ticket_type_id=None, year=None, month=None, day=None, **kw):
from quart import g, abort
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
abort(404)
g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type)
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_ticket_type_main_panel
return await render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
async def _h_tickets_content(**kw):
from quart import g
return getattr(g, "slots_content", "")
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets
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()
return await _tickets_main_panel_html(ctx, tickets)
def _h_slot_content():
async def _h_ticket_detail_content(code=None, **kw):
from quart import g, abort
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
abort(404)
else:
abort(404)
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_detail_panel_html
ctx = await get_template_context()
return await _ticket_detail_panel_html(ctx, ticket)
async def _h_ticket_admin_content(**kw):
from quart import g
return getattr(g, "slot_content", "")
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
return await _ticket_admin_main_panel_html(ctx, tickets, stats)
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", "")
async def _h_markets_content(**kw):
from shared.sx.page import get_template_context
from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
return await _markets_main_panel_html(ctx)

View File

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

View File

@@ -12,7 +12,6 @@ from shared.services.registry import services
from bp import (
register_identity_bp,
register_social_bp,
register_fragments,
)
@@ -94,11 +93,13 @@ def create_app() -> "Quart":
app.register_blueprint(register_identity_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())
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "federation")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "federation")
# --- home page ---
@app.get("/")

View File

@@ -1,3 +1,2 @@
from .identity.routes import register as register_identity_bp
from .social.routes import register as register_social_bp
from .fragments import register_fragments

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Federation app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``federation/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("federation", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "federation", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -32,102 +32,6 @@ def register(url_prefix="/social"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor
@bp.before_request
async def _prepare_page_data():
"""Pre-render content for defpage routes."""
endpoint = request.endpoint or ""
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")
@@ -170,7 +74,7 @@ def register(url_prefix="/social"):
form = await request.form
content = form.get("content", "").strip()
if not content:
return redirect(url_for("social.defpage_compose_form"))
return redirect(url_for("defpage_compose_form"))
visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None
@@ -181,13 +85,13 @@ def register(url_prefix="/social"):
visibility=visibility,
in_reply_to=in_reply_to,
)
return redirect(url_for("social.defpage_home_timeline"))
return redirect(url_for("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.defpage_home_timeline"))
return redirect(url_for("defpage_home_timeline"))
# -- Search + Follow -------------------------------------------------------
@@ -223,7 +127,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.defpage_search"))
return redirect(request.referrer or url_for("defpage_search"))
@bp.post("/unfollow")
async def unfollow():
@@ -236,7 +140,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.defpage_search"))
return redirect(request.referrer or url_for("defpage_search"))
async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX."""
@@ -252,7 +156,7 @@ def register(url_prefix="/social"):
else:
list_type = "following"
from sx.sx_components import render_actor_card
return sx_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
return sx_response(await render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
# -- Interactions ----------------------------------------------------------
@@ -339,7 +243,7 @@ def register(url_prefix="/social"):
)).scalar())
from sx.sx_components import render_interaction_buttons
return sx_response(render_interaction_buttons(
return sx_response(await render_interaction_buttons(
object_id=object_id,
author_inbox=author_inbox,
like_count=like_count,
@@ -414,6 +318,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.defpage_notifications"))
return redirect(url_for("defpage_notifications"))
return bp

View File

@@ -20,3 +20,50 @@
(defcomp ~federation-notifications-page (&key notifs)
(h1 :class "text-2xl font-bold mb-6" "Notifications") notifs)
;; Assembled notification card — replaces Python _notification_sx
(defcomp ~federation-notification-from-data (&key notif)
(let* ((from-name (or (get notif "from_actor_name") "?"))
(from-username (or (get notif "from_actor_username") ""))
(from-domain (or (get notif "from_actor_domain") ""))
(from-icon (get notif "from_actor_icon"))
(ntype (or (get notif "notification_type") ""))
(preview (get notif "target_content_preview"))
(created (or (get notif "created_at_formatted") ""))
(read (get notif "read"))
(app-domain (or (get notif "app_domain") ""))
(border (if (not read) " border-l-4 border-l-stone-400" ""))
(initial (if (and (not from-icon) from-name)
(upper (slice from-name 0 1)) "?"))
(action-text (cond
((= ntype "follow") (str "followed you"
(if (and app-domain (!= app-domain "federation"))
(str " on " (escape app-domain)) "")))
((= ntype "like") "liked your post")
((= ntype "boost") "boosted your post")
((= ntype "mention") "mentioned you")
((= ntype "reply") "replied to your post")
(true ""))))
(~federation-notification-card
:cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border)
:avatar (~avatar
:src from-icon
:cls (if from-icon "w-8 h-8 rounded-full"
"w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs")
:initial (when (not from-icon) initial))
:from-name (escape from-name)
:from-username (escape from-username)
:from-domain (if from-domain (str "@" (escape from-domain)) "")
:action-text action-text
:preview (when preview (~federation-notification-preview :preview (escape preview)))
:time created)))
;; Assembled notifications content — replaces Python _notifications_content_sx
(defcomp ~federation-notifications-content (&key notifications)
(~federation-notifications-page
:notifs (if (empty? notifications)
(~empty-state :message "No notifications yet." :cls "text-stone-500")
(~federation-notifications-list
:items (map (lambda (n)
(~federation-notification-from-data :notif n))
notifications)))))

View File

@@ -53,3 +53,40 @@
(defcomp ~federation-profile-summary-text (&key text)
(p :class "mt-2" text))
;; Assembled actor timeline content — replaces Python _actor_timeline_content_sx
(defcomp ~federation-actor-timeline-content (&key remote-actor items is-following actor)
(let* ((display-name (or (get remote-actor "display_name") (get remote-actor "preferred_username") ""))
(icon-url (get remote-actor "icon_url"))
(summary (get remote-actor "summary"))
(actor-url (or (get remote-actor "actor_url") ""))
(csrf (csrf-token))
(initial (if (and (not icon-url) display-name)
(upper (slice display-name 0 1)) "?")))
(~federation-actor-timeline-layout
:header (~federation-actor-profile-header
:avatar (~avatar
:src icon-url
:cls (if icon-url "w-16 h-16 rounded-full"
"w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl")
:initial (when (not icon-url) initial))
:display-name (escape display-name)
:username (escape (or (get remote-actor "preferred_username") ""))
:domain (escape (or (get remote-actor "domain") ""))
:summary (when summary (~federation-profile-summary :summary summary))
:follow (when actor
(if is-following
(~federation-follow-form
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url
:label "Unfollow"
:cls "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100")
(~federation-follow-form
:action (url-for "social.follow") :csrf csrf :actor-url actor-url
:label "Follow"
:cls "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"))))
:timeline (~federation-timeline-items
:items items :timeline-type "actor" :actor actor
:next-url (when (not (empty? items))
(url-for "social.actor_timeline_page"
:id (get remote-actor "id")
:before (get (last items) "before_cursor")))))))

View File

@@ -60,3 +60,99 @@
(h1 :class "text-2xl font-bold mb-6" title " "
(span :class "text-stone-400 font-normal" count-str))
(div :id "actor-list" items))
;; ---------------------------------------------------------------------------
;; Assembled actor card — replaces Python _actor_card_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-actor-card-from-data (&key a actor followed-urls list-type)
(let* ((display-name (or (get a "display_name") (get a "preferred_username") ""))
(username (or (get a "preferred_username") ""))
(domain (or (get a "domain") ""))
(icon-url (get a "icon_url"))
(actor-url (or (get a "actor_url") ""))
(summary (get a "summary"))
(aid (get a "id"))
(safe-id (replace (replace actor-url "/" "_") ":" "_"))
(initial (if (and (not icon-url) (or display-name username))
(upper (slice (or display-name username) 0 1)) "?"))
(csrf (csrf-token))
(is-followed (contains? (or followed-urls (list)) actor-url)))
(~federation-actor-card
:cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
:id (str "actor-" safe-id)
:avatar (~avatar
:src icon-url
:cls (if icon-url "w-12 h-12 rounded-full"
"w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold")
:initial (when (not icon-url) initial))
:name (if (and (or (= list-type "following") (= list-type "search")) aid)
(~federation-actor-name-link
:href (url-for "social.defpage_actor_timeline" :id aid)
:name (escape display-name))
(~federation-actor-name-link-external
:href (str "https://" domain "/@" username)
:name (escape display-name)))
:username (escape username)
:domain (escape domain)
:summary (when summary (~federation-actor-summary :summary summary))
:button (when actor
(if (or (= list-type "following") is-followed)
(~federation-unfollow-button
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url)
(~federation-follow-button
:action (url-for "social.follow") :csrf csrf :actor-url actor-url
:label (if (= list-type "followers") "Follow Back" "Follow")))))))
;; Assembled search content — replaces Python _search_content_sx
(defcomp ~federation-search-content (&key query actors total followed-urls actor)
(~federation-search-page
:search-url (url-for "social.defpage_search")
:search-page-url (url-for "social.search_page")
:query (escape (or query ""))
:info (cond
((and query (> total 0))
(~federation-search-info
:cls "text-sm text-stone-500 mb-4"
:text (str total " result" (pluralize total) " for " (escape query))))
(query
(~federation-search-info
:cls "text-stone-500 mb-4"
:text (str "No results found for " (escape query))))
(true nil))
:results (when (not (empty? actors))
(<>
(map (lambda (a)
(~federation-actor-card-from-data
:a a :actor actor :followed-urls followed-urls :list-type "search"))
actors)
(when (>= (len actors) 20)
(~federation-scroll-sentinel
:url (url-for "social.search_page" :q query :page 2)))))))
;; Assembled following/followers content — replaces Python _following_content_sx etc.
(defcomp ~federation-following-content (&key actors total actor)
(~federation-actor-list-page
:title "Following" :count-str (str "(" total ")")
:items (when (not (empty? actors))
(<>
(map (lambda (a)
(~federation-actor-card-from-data
:a a :actor actor :followed-urls (list) :list-type "following"))
actors)
(when (>= (len actors) 20)
(~federation-scroll-sentinel
:url (url-for "social.following_list_page" :page 2)))))))
(defcomp ~federation-followers-content (&key actors total followed-urls actor)
(~federation-actor-list-page
:title "Followers" :count-str (str "(" total ")")
:items (when (not (empty? actors))
(<>
(map (lambda (a)
(~federation-actor-card-from-data
:a a :actor actor :followed-urls followed-urls :list-type "followers"))
actors)
(when (>= (len actors) 20)
(~federation-scroll-sentinel
:url (url-for "social.followers_list_page" :page 2)))))))

View File

@@ -110,3 +110,129 @@
(option :value "unlisted" "Unlisted")
(option :value "followers" "Followers only"))
(button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish"))))
;; ---------------------------------------------------------------------------
;; Assembled social nav — replaces Python _social_nav_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-social-nav (&key actor)
(if (not actor)
(~federation-nav-choose-username :url (url-for "identity.choose_username_form"))
(let* ((rp (request-path))
(links (list
(dict :endpoint "social.defpage_home_timeline" :label "Timeline")
(dict :endpoint "social.defpage_public_timeline" :label "Public")
(dict :endpoint "social.defpage_compose_form" :label "Compose")
(dict :endpoint "social.defpage_following_list" :label "Following")
(dict :endpoint "social.defpage_followers_list" :label "Followers")
(dict :endpoint "social.defpage_search" :label "Search"))))
(~federation-nav-bar
:items (<>
(map (lambda (lnk)
(let* ((href (url-for (get lnk "endpoint")))
(bold (if (= rp href) " font-bold" "")))
(a :href href
:class (str "px-2 py-1 rounded hover:bg-stone-200" bold)
(get lnk "label"))))
links)
(let* ((notif-url (url-for "social.defpage_notifications"))
(notif-bold (if (= rp notif-url) " font-bold" "")))
(~federation-nav-notification-link
:href notif-url
:cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold)
:count-url (url-for "social.notification_count")))
(a :href (url-for "activitypub.actor_profile" :username (get actor "preferred_username"))
:class "px-2 py-1 rounded hover:bg-stone-200"
(str "@" (get actor "preferred_username"))))))))
;; ---------------------------------------------------------------------------
;; Assembled post card — replaces Python _post_card_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-post-card-from-data (&key item actor)
(let* ((boosted-by (get item "boosted_by"))
(actor-icon (get item "actor_icon"))
(actor-name (or (get item "actor_name") "?"))
(actor-username (or (get item "actor_username") ""))
(actor-domain (or (get item "actor_domain") ""))
(content (or (get item "content") ""))
(summary (get item "summary"))
(published (or (get item "published") ""))
(url (get item "url"))
(post-type (or (get item "post_type") ""))
(oid (or (get item "object_id") ""))
(safe-id (replace (replace oid "/" "_") ":" "_"))
(initial (if (and (not actor-icon) actor-name)
(upper (slice actor-name 0 1)) "?")))
(~federation-post-card
:boost (when boosted-by (~federation-boost-label :name (escape boosted-by)))
:avatar (~avatar
:src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
:initial (when (not actor-icon) initial))
:actor-name (escape actor-name)
:actor-username (escape actor-username)
:domain (if actor-domain (str "@" (escape actor-domain)) "")
:time published
:content (if summary
(~federation-content :content content :summary (escape summary))
(~federation-content :content content))
:original (when (and url (= post-type "remote"))
(~federation-original-link :url url))
:interactions (when actor
(let* ((csrf (csrf-token))
(liked (get item "liked_by_me"))
(boosted-me (get item "boosted_by_me"))
(lcount (or (get item "like_count") 0))
(bcount (or (get item "boost_count") 0))
(ainbox (or (get item "author_inbox") ""))
(target (str "#interactions-" safe-id)))
(div :id (str "interactions-" safe-id)
(~federation-interaction-buttons
:like (~federation-like-form
:action (url-for (if liked "social.unlike" "social.like"))
:target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500"))
:icon (if liked "\u2665" "\u2661") :count (str lcount))
:boost (~federation-boost-form
:action (url-for (if boosted-me "social.unboost" "social.boost"))
:target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600"))
:count (str bcount))
:reply (when oid
(~federation-reply-link
:url (url-for "social.defpage_compose_form" :reply-to oid))))))))))
;; ---------------------------------------------------------------------------
;; Assembled timeline items — replaces Python _timeline_items_sx
;; ---------------------------------------------------------------------------
(defcomp ~federation-timeline-items (&key items timeline-type actor next-url)
(<>
(map (lambda (item)
(~federation-post-card-from-data :item item :actor actor))
items)
(when next-url
(~federation-scroll-sentinel :url next-url))))
;; Assembled timeline content — replaces Python _timeline_content_sx
(defcomp ~federation-timeline-content (&key items timeline-type actor)
(let* ((label (if (= timeline-type "home") "Home" "Public")))
(~federation-timeline-page
:label label
:compose (when actor
(~federation-compose-button :url (url-for "social.defpage_compose_form")))
:timeline (~federation-timeline-items
:items items :timeline-type timeline-type :actor actor
:next-url (when (not (empty? items))
(url-for (str "social." timeline-type "_timeline_page")
:before (get (last items) "before_cursor")))))))
;; Assembled compose content — replaces Python _compose_content_sx
(defcomp ~federation-compose-content (&key reply-to)
(~federation-compose-form
:action (url-for "social.compose_submit")
:csrf (csrf-token)
:reply (when reply-to
(~federation-compose-reply :reply-to (escape reply-to)))))

View File

@@ -1,8 +1,10 @@
"""
Federation service s-expression page components.
Renders social timeline, compose, search, following/followers, notifications,
actor profiles, login, and username selection pages.
Page helpers now call assembled defcomps in .sx files. This file contains
only functions still called directly from route handlers: full-page renders
(login, choose-username, profile) and POST fragment renderers (interaction
buttons, actor cards, pagination items).
"""
from __future__ import annotations
@@ -11,9 +13,8 @@ from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.parser import serialize
from shared.sx.helpers import (
sx_call, SxExpr,
render_to_sx,
root_header_sx, full_page_sx, header_child_sx,
)
@@ -23,629 +24,106 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)),
# ---------------------------------------------------------------------------
# Social header nav
# Serialization helpers (shared with pages/__init__.py)
# ---------------------------------------------------------------------------
def _social_nav_sx(actor: Any) -> str:
"""Build the social header nav bar content."""
from quart import url_for, request
def _serialize_actor(actor) -> dict | None:
if not actor:
choose_url = url_for("identity.choose_username_form")
return sx_call("federation-nav-choose-username", url=choose_url)
links = [
("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 = []
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
cls = f"px-2 py-1 rounded hover:bg-stone-200{bold}"
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
# Notifications with live badge
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(
"federation-nav-notification-link",
href=notif_url,
cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
count_url=notif_count_url,
))
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(f'(a :href {serialize(profile_url)} :class "px-2 py-1 rounded hover:bg-stone-200" {serialize("@" + actor.preferred_username)})')
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("federation-nav-bar", items=SxExpr(items_sx))
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"summary": getattr(actor, "summary", None),
"actor_url": getattr(actor, "actor_url", ""),
"domain": getattr(actor, "domain", ""),
}
def _social_header_sx(actor: Any) -> str:
"""Build the social section header row."""
nav_sx = _social_nav_sx(actor)
return sx_call("federation-social-header", nav=SxExpr(nav_sx))
def _serialize_timeline_item(item) -> dict:
published = getattr(item, "published", None)
return {
"object_id": getattr(item, "object_id", "") or "",
"author_inbox": getattr(item, "author_inbox", "") or "",
"actor_icon": getattr(item, "actor_icon", None),
"actor_name": getattr(item, "actor_name", "?"),
"actor_username": getattr(item, "actor_username", ""),
"actor_domain": getattr(item, "actor_domain", ""),
"content": getattr(item, "content", ""),
"summary": getattr(item, "summary", None),
"published": published.strftime("%b %d, %H:%M") if published else "",
"before_cursor": published.isoformat() if published else "",
"url": getattr(item, "url", None),
"post_type": getattr(item, "post_type", ""),
"boosted_by": getattr(item, "boosted_by", None),
"like_count": getattr(item, "like_count", 0) or 0,
"boost_count": getattr(item, "boost_count", 0) or 0,
"liked_by_me": getattr(item, "liked_by_me", False),
"boosted_by_me": getattr(item, "boosted_by_me", False),
}
def _social_page(ctx: dict, actor: Any, *, content: str,
def _serialize_remote_actor(a) -> dict:
return {
"id": getattr(a, "id", None),
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
"preferred_username": getattr(a, "preferred_username", ""),
"domain": getattr(a, "domain", ""),
"icon_url": getattr(a, "icon_url", None),
"actor_url": getattr(a, "actor_url", ""),
"summary": getattr(a, "summary", None),
}
# ---------------------------------------------------------------------------
# Social page shell
# ---------------------------------------------------------------------------
async def _social_page(ctx: dict, actor: Any, *, content: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
"""Render a social page with header and content."""
hdr = root_header_sx(ctx)
social_hdr = _social_header_sx(actor)
child = header_child_sx(social_hdr)
from shared.sx.parser import SxExpr
actor_data = _serialize_actor(actor)
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
hdr = await root_header_sx(ctx)
child = await header_child_sx(social_hdr)
header_rows = "(<> " + hdr + " " + child + ")"
return full_page_sx(ctx, header_rows=header_rows, content=content,
return await full_page_sx(ctx, header_rows=header_rows, content=content,
meta_html=meta_html or f'<title>{escape(title)}</title>')
# ---------------------------------------------------------------------------
# Post card
# ---------------------------------------------------------------------------
def _interaction_buttons_sx(item: Any, actor: Any) -> str:
"""Render like/boost/reply buttons for a post."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
oid = getattr(item, "object_id", "") or ""
ainbox = getattr(item, "author_inbox", "") or ""
lcount = getattr(item, "like_count", 0) or 0
bcount = getattr(item, "boost_count", 0) or 0
liked = getattr(item, "liked_by_me", False)
boosted = getattr(item, "boosted_by_me", False)
csrf = generate_csrf_token()
safe_id = oid.replace("/", "_").replace(":", "_")
target = f"#interactions-{safe_id}"
if liked:
like_action = url_for("social.unlike")
like_cls = "text-red-500 hover:text-red-600"
like_icon = "\u2665"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
like_icon = "\u2661"
if boosted:
boost_action = url_for("social.unboost")
boost_cls = "text-green-600 hover:text-green-700"
else:
boost_action = url_for("social.boost")
boost_cls = "hover:text-green-600"
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(
"federation-like-form",
action=like_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
icon=like_icon, count=str(lcount),
)
boost_form = sx_call(
"federation-boost-form",
action=boost_action, target=target, oid=oid, ainbox=ainbox,
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
count=str(bcount),
)
return sx_call(
"federation-interaction-buttons",
like=SxExpr(like_form),
boost=SxExpr(boost_form),
reply=SxExpr(reply_sx) if reply_sx else None,
)
def _post_card_sx(item: Any, actor: Any) -> str:
"""Render a single timeline post card."""
boosted_by = getattr(item, "boosted_by", None)
actor_icon = getattr(item, "actor_icon", None)
actor_name = getattr(item, "actor_name", "?")
actor_username = getattr(item, "actor_username", "")
actor_domain = getattr(item, "actor_domain", "")
content = getattr(item, "content", "")
summary = getattr(item, "summary", None)
published = getattr(item, "published", None)
url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "")
boost_sx = sx_call(
"federation-boost-label", name=str(escape(boosted_by)),
) if boosted_by else ""
initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?"
avatar = sx_call(
"avatar", src=actor_icon or None,
cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=None if actor_icon else initial,
)
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
time_str = published.strftime("%b %d, %H:%M") if published else ""
if summary:
content_sx = sx_call(
"federation-content",
content=content, summary=str(escape(summary)),
)
else:
content_sx = sx_call("federation-content", content=content)
original_sx = ""
if url and post_type == "remote":
original_sx = sx_call("federation-original-link", url=url)
interactions_sx = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})'
return sx_call(
"federation-post-card",
boost=SxExpr(boost_sx) if boost_sx else None,
avatar=SxExpr(avatar),
actor_name=str(escape(actor_name)),
actor_username=str(escape(actor_username)),
domain=domain_str, time=time_str,
content=SxExpr(content_sx),
original=SxExpr(original_sx) if original_sx else None,
interactions=SxExpr(interactions_sx) if interactions_sx else None,
)
# ---------------------------------------------------------------------------
# Timeline items (pagination fragment)
# ---------------------------------------------------------------------------
def _timeline_items_sx(items: list, timeline_type: str, actor: Any,
actor_id: int | None = None) -> str:
"""Render timeline items with infinite scroll sentinel."""
from quart import url_for
parts = [_post_card_sx(item, actor) for item in items]
if items:
last = items[-1]
before = last.published.isoformat() if last.published else ""
if timeline_type == "actor" and actor_id is not None:
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Search results (pagination fragment)
# ---------------------------------------------------------------------------
def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
*, list_type: str = "search") -> str:
"""Render a single actor card with follow/unfollow button."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
username = getattr(a, "preferred_username", "")
domain = getattr(a, "domain", "")
icon_url = getattr(a, "icon_url", None)
actor_url = getattr(a, "actor_url", "")
summary = getattr(a, "summary", None)
aid = getattr(a, "id", None)
safe_id = actor_url.replace("/", "_").replace(":", "_")
initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?"
avatar = sx_call(
"avatar", src=icon_url or None,
cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
initial=None if icon_url else initial,
)
# Name link
if (list_type in ("following", "search")) and aid:
name_sx = sx_call(
"federation-actor-name-link",
href=url_for("social.defpage_actor_timeline", id=aid),
name=str(escape(display_name)),
)
else:
name_sx = sx_call(
"federation-actor-name-link-external",
href=f"https://{domain}/@{username}",
name=str(escape(display_name)),
)
summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else ""
# Follow/unfollow button
button_sx = ""
if actor:
is_followed = actor_url in (followed_urls or set())
if list_type == "following" or is_followed:
button_sx = sx_call(
"federation-unfollow-button",
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
button_sx = sx_call(
"federation-follow-button",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
)
return sx_call(
"federation-actor-card",
cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4",
id=f"actor-{safe_id}",
avatar=SxExpr(avatar),
name=SxExpr(name_sx),
username=str(escape(username)), domain=str(escape(domain)),
summary=SxExpr(summary_sx) if summary_sx else None,
button=SxExpr(button_sx) if button_sx else None,
)
def _search_results_sx(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Render search results with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, actor, followed_urls, list_type="search") for a in actors]
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
def _actor_list_items_sx(actors: list, page: int, list_type: str,
followed_urls: set, actor: Any) -> str:
"""Render actor list items (following/followers) with pagination sentinel."""
from quart import url_for
parts = [_actor_card_sx(a, actor, followed_urls, list_type=list_type) for a in actors]
if len(actors) >= 20:
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Notification card
# ---------------------------------------------------------------------------
def _notification_sx(notif: Any) -> str:
"""Render a single notification."""
from_name = getattr(notif, "from_actor_name", "?")
from_username = getattr(notif, "from_actor_username", "")
from_domain = getattr(notif, "from_actor_domain", "")
from_icon = getattr(notif, "from_actor_icon", None)
ntype = getattr(notif, "notification_type", "")
preview = getattr(notif, "target_content_preview", None)
created = getattr(notif, "created_at", None)
read = getattr(notif, "read", True)
app_domain = getattr(notif, "app_domain", "")
border = " border-l-4 border-l-stone-400" if not read else ""
initial = from_name[0].upper() if (not from_icon and from_name) else "?"
avatar = sx_call(
"avatar", src=from_icon or None,
cls="w-8 h-8 rounded-full" if from_icon else "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
initial=None if from_icon else initial,
)
domain_str = f"@{escape(from_domain)}" if from_domain else ""
type_map = {
"follow": "followed you",
"like": "liked your post",
"boost": "boosted your post",
"mention": "mentioned you",
"reply": "replied to your post",
}
action = type_map.get(ntype, "")
if ntype == "follow" and app_domain and app_domain != "federation":
action += f" on {escape(app_domain)}"
preview_sx = sx_call(
"federation-notification-preview", preview=str(escape(preview)),
) if preview else ""
time_str = created.strftime("%b %d, %H:%M") if created else ""
return sx_call(
"federation-notification-card",
cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
avatar=SxExpr(avatar),
from_name=str(escape(from_name)),
from_username=str(escape(from_username)),
from_domain=domain_str, action_text=action,
preview=SxExpr(preview_sx) if preview_sx else None,
time=time_str,
)
# ---------------------------------------------------------------------------
# Public API: Home page
# Public API: Full page renders
# ---------------------------------------------------------------------------
async def render_federation_home(ctx: dict) -> str:
"""Full page: federation home (minimal)."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr)
hdr = await root_header_sx(ctx)
return await full_page_sx(ctx, header_rows=hdr)
# ---------------------------------------------------------------------------
# Public API: Login
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: federation login form."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
email = ctx.get("email", "")
action = url_for("auth.start_login")
csrf = generate_csrf_token()
error_sx = sx_call("auth-error-banner", error=error) if error else ""
content = sx_call(
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
action=action, csrf_token=csrf,
email=str(escape(email)),
)
return _social_page(ctx, None, content=content,
title="Login \u2014 Rose Ash")
content = await render_to_sx("account-login-content",
error=error or None, email=str(escape(email)))
return await _social_page(ctx, None, content=content, title="Login \u2014 Rose Ash")
async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
error_sx = sx_call(
"auth-check-email-error", error=str(escape(email_error)),
) if email_error else ""
content = sx_call(
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
return _social_page(ctx, None, content=content,
content = await render_to_sx("account-check-email-content",
email=str(escape(email)), email_error=email_error)
return await _social_page(ctx, None, content=content,
title="Check your email \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Content builders (used by defpage before_request)
# ---------------------------------------------------------------------------
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.defpage_compose_form")
compose_sx = sx_call("federation-compose-button", url=compose_url)
timeline_sx = _timeline_items_sx(items, timeline_type, actor)
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,
)
async def render_timeline_items(items: list, timeline_type: str,
actor: Any, actor_id: int | None = None) -> str:
"""Pagination fragment: timeline items."""
return _timeline_items_sx(items, timeline_type, actor, actor_id)
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
csrf = generate_csrf_token()
action = url_for("social.compose_submit")
reply_sx = ""
if reply_to:
reply_sx = sx_call(
"federation-compose-reply",
reply_to=str(escape(reply_to)),
)
return sx_call(
"federation-compose-form",
action=action, csrf=csrf,
reply=SxExpr(reply_sx) if reply_sx else None,
)
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.defpage_search")
search_page_url = url_for("social.search_page")
results_sx = _search_results_sx(actors, query, page, followed_urls, actor)
info_sx = ""
if query and total:
s = "s" if total != 1 else ""
info_sx = sx_call(
"federation-search-info",
cls="text-sm text-stone-500 mb-4",
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
)
elif query:
info_sx = sx_call(
"federation-search-info",
cls="text-stone-500 mb-4",
text=f"No results found for <strong>{escape(query)}</strong>",
)
return sx_call(
"federation-search-page",
search_url=search_url, search_page_url=search_page_url,
query=str(escape(query)),
info=SxExpr(info_sx) if info_sx else None,
results=SxExpr(results_sx) if results_sx else None,
)
async def render_search_results(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: search results."""
return _search_results_sx(actors, query, page, followed_urls, actor)
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)
return sx_call(
"federation-actor-list-page",
title="Following", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None,
)
async def render_following_items(actors: list, page: int, actor: Any) -> str:
"""Pagination fragment: following items."""
return _actor_list_items_sx(actors, page, "following", set(), actor)
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)
return sx_call(
"federation-actor-list-page",
title="Followers", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None,
)
async def render_followers_items(actors: list, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: followers items."""
return _actor_list_items_sx(actors, page, "followers", followed_urls, actor)
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
csrf = generate_csrf_token()
display_name = remote_actor.display_name or remote_actor.preferred_username
icon_url = getattr(remote_actor, "icon_url", None)
summary = getattr(remote_actor, "summary", None)
actor_url = getattr(remote_actor, "actor_url", "")
initial = display_name[0].upper() if (not icon_url and display_name) else "?"
avatar = sx_call(
"avatar", src=icon_url or None,
cls="w-16 h-16 rounded-full" if icon_url else "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
initial=None if icon_url else initial,
)
summary_sx = sx_call("federation-profile-summary", summary=summary) if summary else ""
follow_sx = ""
if actor:
if is_following:
follow_sx = sx_call(
"federation-follow-form",
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
label="Unfollow",
cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100",
)
else:
follow_sx = sx_call(
"federation-follow-form",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url,
label="Follow",
cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700",
)
timeline_sx = _timeline_items_sx(items, "actor", actor, remote_actor.id)
header_sx = sx_call(
"federation-actor-profile-header",
avatar=SxExpr(avatar),
display_name=str(escape(display_name)),
username=str(escape(remote_actor.preferred_username)),
domain=str(escape(remote_actor.domain)),
summary=SxExpr(summary_sx) if summary_sx else None,
follow=SxExpr(follow_sx) if follow_sx else None,
)
return sx_call(
"federation-actor-timeline-layout",
header=SxExpr(header_sx),
timeline=SxExpr(timeline_sx) if timeline_sx else None,
)
async def render_actor_timeline_items(items: list, actor_id: int,
actor: Any) -> str:
"""Pagination fragment: actor timeline items."""
return _timeline_items_sx(items, "actor", actor, actor_id)
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")
else:
items_sx = "(<> " + " ".join(_notification_sx(n) for n in notifications) + ")"
notif_sx = sx_call(
"federation-notifications-list",
items=SxExpr(items_sx),
)
return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
# ---------------------------------------------------------------------------
# Public API: Choose username
# ---------------------------------------------------------------------------
async def render_choose_username_page(ctx: dict) -> str:
"""Full page: choose username form."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
from shared.config import config
from shared.sx.parser import SxExpr
csrf = generate_csrf_token()
error = ctx.get("error", "")
@@ -654,89 +132,162 @@ async def render_choose_username_page(ctx: dict) -> str:
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
error_sx = sx_call("auth-error-banner", error=error) if error else ""
content = sx_call(
error_sx = await render_to_sx("auth-error-banner", error=error) if error else ""
content = await render_to_sx(
"federation-choose-username",
domain=str(escape(ap_domain)),
error=SxExpr(error_sx) if error_sx else None,
csrf=csrf, username=str(escape(username)),
check_url=check_url,
)
return _social_page(ctx, actor, content=content,
return await _social_page(ctx, actor, content=content,
title="Choose Username \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: Actor profile
# Public API: Pagination fragment renderers
# ---------------------------------------------------------------------------
async def render_profile_page(ctx: dict, actor: Any, activities: list,
total: int) -> str:
"""Full page: actor profile."""
from shared.config import config
async def render_timeline_items(items: list, timeline_type: str,
actor: Any, actor_id: int | None = None) -> str:
from quart import url_for
item_dicts = [_serialize_timeline_item(i) for i in items]
actor_data = _serialize_actor(actor)
ap_domain = config().get("ap_domain", "rose-ash.com")
display_name = actor.display_name or actor.preferred_username
summary_sx = sx_call(
"federation-profile-summary-text", text=str(escape(actor.summary)),
) if actor.summary else ""
# Build next URL
next_url = None
if items:
last = items[-1]
before = last.published.isoformat() if last.published else ""
if timeline_type == "actor" and actor_id is not None:
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
activities_sx = ""
if activities:
parts = []
for a in activities:
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
obj_type_sx = sx_call(
"federation-activity-obj-type", obj_type=a.object_type,
) if a.object_type else ""
parts.append(sx_call(
"federation-activity-card",
activity_type=a.activity_type, published=published,
obj_type=SxExpr(obj_type_sx) if obj_type_sx else None,
))
items_sx = "(<> " + " ".join(parts) + ")"
activities_sx = sx_call("federation-activities-list", items=SxExpr(items_sx))
else:
activities_sx = sx_call("federation-activities-empty")
return await render_to_sx("federation-timeline-items",
items=item_dicts,
timeline_type=timeline_type,
actor=actor_data,
next_url=next_url)
content = sx_call(
"federation-profile-page",
display_name=str(escape(display_name)),
username=str(escape(actor.preferred_username)),
domain=str(escape(ap_domain)),
summary=SxExpr(summary_sx) if summary_sx else None,
activities_heading=f"Activities ({total})",
activities=SxExpr(activities_sx),
)
return _social_page(ctx, actor, content=content,
title=f"@{actor.preferred_username} \u2014 Rose Ash")
async def render_search_results(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
from quart import url_for
actor_dicts = [_serialize_remote_actor(a) for a in actors]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type="search"))
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
async def render_following_items(actors: list, page: int, actor: Any) -> str:
from quart import url_for
actor_dicts = [_serialize_remote_actor(a) for a in actors]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=[],
list_type="following"))
if len(actors) >= 20:
next_url = url_for("social.following_list_page", page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
async def render_followers_items(actors: list, page: int,
followed_urls: set, actor: Any) -> str:
from quart import url_for
actor_dicts = [_serialize_remote_actor(a) for a in actors]
actor_data = _serialize_actor(actor)
parts = []
for ad in actor_dicts:
parts.append(await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type="followers"))
if len(actors) >= 20:
next_url = url_for("social.followers_list_page", page=page + 1)
parts.append(await render_to_sx("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else ""
async def render_actor_timeline_items(items: list, actor_id: int,
actor: Any) -> str:
return await render_timeline_items(items, "actor", actor, actor_id)
# ---------------------------------------------------------------------------
# Public API: POST handler fragment renderers
# ---------------------------------------------------------------------------
def render_interaction_buttons(object_id: str, author_inbox: str,
async def render_interaction_buttons(object_id: str, author_inbox: str,
like_count: int, boost_count: int,
liked_by_me: bool, boosted_by_me: bool,
actor: Any) -> str:
"""Render interaction buttons fragment for HTMX POST response."""
from types import SimpleNamespace
item = SimpleNamespace(
object_id=object_id,
author_inbox=author_inbox,
like_count=like_count,
boost_count=boost_count,
liked_by_me=liked_by_me,
boosted_by_me=boosted_by_me,
)
return _interaction_buttons_sx(item, actor)
"""Render interaction buttons fragment for POST response."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
from shared.sx.parser import SxExpr
csrf = generate_csrf_token()
safe_id = object_id.replace("/", "_").replace(":", "_")
target = f"#interactions-{safe_id}"
if liked_by_me:
like_action = url_for("social.unlike")
like_cls = "text-red-500 hover:text-red-600"
like_icon = "\u2665"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
like_icon = "\u2661"
if boosted_by_me:
boost_action = url_for("social.unboost")
boost_cls = "text-green-600 hover:text-green-700"
else:
boost_action = url_for("social.boost")
boost_cls = "hover:text-green-600"
reply_url = url_for("social.defpage_compose_form", reply_to=object_id) if object_id else ""
reply_sx = await render_to_sx("federation-reply-link", url=reply_url) if reply_url else ""
like_form = await render_to_sx("federation-like-form",
action=like_action, target=target, oid=object_id, ainbox=author_inbox,
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
icon=like_icon, count=str(like_count))
boost_form = await render_to_sx("federation-boost-form",
action=boost_action, target=target, oid=object_id, ainbox=author_inbox,
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
count=str(boost_count))
return await render_to_sx("federation-interaction-buttons",
like=SxExpr(like_form),
boost=SxExpr(boost_form),
reply=SxExpr(reply_sx) if reply_sx else None)
def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
async def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
*, list_type: str = "following") -> str:
"""Render a single actor card fragment for HTMX POST response."""
return _actor_card_sx(actor_dto, actor, followed_urls, list_type=list_type)
"""Render a single actor card fragment for POST response."""
actor_data = _serialize_actor(actor)
ad = _serialize_remote_actor(actor_dto)
return await render_to_sx("federation-actor-card-from-data",
a=ad,
actor=actor_data,
followed_urls=list(followed_urls),
list_type=list_type)

View File

@@ -26,27 +26,31 @@ def _register_federation_layouts() -> None:
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
async def _social_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx, render_to_sx
from shared.sx.parser import SxExpr
actor = ctx.get("actor")
root_hdr = root_header_sx(ctx)
social_hdr = _social_header_sx(actor)
child = header_child_sx(social_hdr)
actor_data = _serialize_actor(actor) if actor else None
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
root_hdr = await root_header_sx(ctx)
child = await 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
async def _social_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
actor = ctx.get("actor")
social_hdr = _social_header_sx(actor)
child_oob = sx_call("oob-header-sx",
actor_data = _serialize_actor(actor) if actor else None
nav = await render_to_sx("federation-social-nav", actor=actor_data)
social_hdr = await render_to_sx("federation-social-header", nav=SxExpr(nav))
child_oob = await render_to_sx("oob-header-sx",
parent_id="root-header-child",
row=SxExpr(social_hdr))
root_hdr_oob = root_header_sx(ctx, oob=True)
root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + root_hdr_oob + ")"
@@ -69,41 +73,221 @@ def _register_federation_helpers() -> None:
})
def _h_home_timeline_content():
def _serialize_actor(actor) -> dict | None:
"""Serialize an actor profile to a dict for sx defcomps."""
if not actor:
return None
return {
"id": actor.id,
"preferred_username": actor.preferred_username,
"display_name": getattr(actor, "display_name", None),
"icon_url": getattr(actor, "icon_url", None),
"summary": getattr(actor, "summary", None),
"actor_url": getattr(actor, "actor_url", ""),
"domain": getattr(actor, "domain", ""),
}
def _serialize_timeline_item(item) -> dict:
"""Serialize a timeline item DTO to a dict for sx defcomps."""
published = getattr(item, "published", None)
return {
"object_id": getattr(item, "object_id", "") or "",
"author_inbox": getattr(item, "author_inbox", "") or "",
"actor_icon": getattr(item, "actor_icon", None),
"actor_name": getattr(item, "actor_name", "?"),
"actor_username": getattr(item, "actor_username", ""),
"actor_domain": getattr(item, "actor_domain", ""),
"content": getattr(item, "content", ""),
"summary": getattr(item, "summary", None),
"published": published.strftime("%b %d, %H:%M") if published else "",
"before_cursor": published.isoformat() if published else "",
"url": getattr(item, "url", None),
"post_type": getattr(item, "post_type", ""),
"boosted_by": getattr(item, "boosted_by", None),
"like_count": getattr(item, "like_count", 0) or 0,
"boost_count": getattr(item, "boost_count", 0) or 0,
"liked_by_me": getattr(item, "liked_by_me", False),
"boosted_by_me": getattr(item, "boosted_by_me", False),
}
def _serialize_remote_actor(a) -> dict:
"""Serialize a remote actor DTO to a dict for sx defcomps."""
return {
"id": getattr(a, "id", None),
"display_name": getattr(a, "display_name", None) or getattr(a, "preferred_username", ""),
"preferred_username": getattr(a, "preferred_username", ""),
"domain": getattr(a, "domain", ""),
"icon_url": getattr(a, "icon_url", None),
"actor_url": getattr(a, "actor_url", ""),
"summary": getattr(a, "summary", None),
}
def _get_actor():
"""Return current user's actor or None."""
from quart import g
return getattr(g, "home_timeline_content", "")
return getattr(g, "_social_actor", None)
def _h_public_timeline_content():
def _require_actor():
"""Return current user's actor or abort 403."""
from quart import abort
actor = _get_actor()
if not actor:
abort(403, "You need to choose a federation username first")
return actor
async def _h_home_timeline_content(**kw):
from quart import g
return getattr(g, "public_timeline_content", "")
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
return await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="home",
actor=_serialize_actor(actor))
def _h_compose_content():
async def _h_public_timeline_content(**kw):
from quart import g
return getattr(g, "compose_content", "")
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
items = await services.federation.get_public_timeline(g.s)
return await render_to_sx("federation-timeline-content",
items=[_serialize_timeline_item(i) for i in items],
timeline_type="public",
actor=_serialize_actor(actor))
def _h_search_content():
async def _h_compose_content(**kw):
from quart import request
from shared.sx.helpers import render_to_sx
_require_actor()
reply_to = request.args.get("reply_to")
return await render_to_sx("federation-compose-content",
reply_to=reply_to or None)
async def _h_search_content(**kw):
from quart import g, request
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
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}
return await render_to_sx("federation-search-content",
query=query,
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_serialize_actor(actor))
async def _h_following_content(**kw):
from quart import g
return getattr(g, "search_content", "")
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
return await render_to_sx("federation-following-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
actor=_serialize_actor(actor))
def _h_following_content():
async def _h_followers_content(**kw):
from quart import g
return getattr(g, "following_content", "")
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
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}
return await render_to_sx("federation-followers-content",
actors=[_serialize_remote_actor(a) for a in actors_list],
total=total,
followed_urls=list(followed_urls),
actor=_serialize_actor(actor))
def _h_followers_content():
async def _h_actor_timeline_content(id=None, **kw):
from quart import g, abort
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _get_actor()
actor_id = 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
return await render_to_sx("federation-actor-timeline-content",
remote_actor=_serialize_remote_actor(remote_dto),
items=[_serialize_timeline_item(i) for i in items],
is_following=is_following,
actor=_serialize_actor(actor))
async def _h_notifications_content(**kw):
from quart import g
return getattr(g, "followers_content", "")
from shared.services.registry import services
from shared.sx.helpers import render_to_sx
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
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", "")
notif_dicts = []
for n in items:
created = getattr(n, "created_at", None)
notif_dicts.append({
"from_actor_name": getattr(n, "from_actor_name", "?"),
"from_actor_username": getattr(n, "from_actor_username", ""),
"from_actor_domain": getattr(n, "from_actor_domain", ""),
"from_actor_icon": getattr(n, "from_actor_icon", None),
"notification_type": getattr(n, "notification_type", ""),
"target_content_preview": getattr(n, "target_content_preview", None),
"created_at_formatted": created.strftime("%b %d, %H:%M") if created else "",
"read": getattr(n, "read", True),
"app_domain": getattr(n, "app_domain", ""),
})
return await render_to_sx("federation-notifications-content",
notifications=notif_dicts)

View File

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

View File

@@ -1,32 +0,0 @@
{% extends "_types/social/index.html" %}
{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="py-8">
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h1 class="text-2xl font-bold">{{ actor.display_name or actor.preferred_username }}</h1>
<p class="text-stone-500">@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}</p>
{% if actor.summary %}
<p class="mt-2">{{ actor.summary }}</p>
{% endif %}
</div>
<h2 class="text-xl font-bold mb-4">Activities ({{ total }})</h2>
{% if activities %}
<div class="space-y-4">
{% for a in activities %}
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<span class="font-medium">{{ a.activity_type }}</span>
<span class="text-sm text-stone-400">{{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }}</span>
</div>
{% if a.object_type %}
<span class="text-sm text-stone-500">{{ a.object_type }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500">No activities yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -31,7 +31,7 @@ def register() -> Blueprint:
async def _is_liked():
"""Check if a user has liked a specific target."""
from sqlalchemy import select
from likes.models.like import Like
from models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")
@@ -62,7 +62,7 @@ def register() -> Blueprint:
async def _liked_slugs():
"""Return all liked target_slugs for a user + target_type."""
from sqlalchemy import select
from likes.models.like import Like
from models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")
@@ -86,7 +86,7 @@ def register() -> Blueprint:
async def _liked_ids():
"""Return all liked target_ids for a user + target_type."""
from sqlalchemy import select
from likes.models.like import Like
from models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")

View File

@@ -11,7 +11,7 @@ from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from shared.config import config
from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_fragments, register_actions, register_data
from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_actions, register_data
async def market_context() -> dict:
@@ -103,21 +103,16 @@ def create_app() -> "Quart":
from sxc.pages import setup_market_pages
setup_market_pages()
from shared.sx.pages import mount_pages
# All markets: / — global view across all pages
all_markets_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
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
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>/
@@ -131,10 +126,16 @@ def create_app() -> "Quart":
url_prefix="/<page_slug>/<market_slug>",
)
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "market")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "market")
# --- Auto-inject slugs into url_for() calls ---
@app.url_value_preprocessor
def pull_slugs(endpoint, values):

View File

@@ -3,6 +3,5 @@ from .product.routes import register as register_product
from .all_markets.routes import register as register_all_markets
from .page_markets.routes import register as register_page_markets
from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

@@ -41,19 +41,6 @@ async def _load_markets(page, per_page=20):
def register() -> Blueprint:
bp = Blueprint("all_markets", __name__)
@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)
g.all_markets_data = {
"markets": markets, "has_more": has_more,
"page_info": page_info, "page": page,
}
@bp.get("/all-markets")
async def markets_fragment():
page = int(request.args.get("page", 1))

View File

@@ -29,10 +29,6 @@ def register():
register_product(),
)
# 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")
async def browse_all():

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Market app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``market/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("market", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "market", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -5,9 +5,4 @@ from quart import Blueprint
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
# Mount defpage for market admin (GET /)
from shared.sx.pages import mount_pages
mount_pages(bp, "market", names=["market-admin"])
return bp

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, make_response, url_for
from quart import Blueprint, g, make_response, url_for
from ..browse.routes import register as register_browse_bp

View File

@@ -26,17 +26,6 @@ def _slugify(value: str, max_len: int = 255) -> str:
def register():
bp = Blueprint("page_admin", __name__)
@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 _markets_admin_panel_sx
ctx = await get_template_context()
g.page_admin_content = await _markets_admin_panel_sx(ctx)
@bp.post("/new/")
@require_admin
async def create_market(**kwargs):

View File

@@ -23,20 +23,6 @@ async def _load_markets(post_id, page, per_page=20):
def register() -> Blueprint:
bp = Blueprint("page_markets", __name__)
@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)
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():
post = g.post_data["post"]

View File

@@ -129,7 +129,7 @@ def register():
from sx.sx_components import render_like_toggle_button
if not g.user:
return sx_response(render_like_toggle_button(product_slug, False), status=403)
return sx_response(await render_like_toggle_button(product_slug, False), status=403)
user_id = g.user.id
@@ -138,7 +138,7 @@ def register():
})
liked = result["liked"]
return sx_response(render_like_toggle_button(product_slug, liked))
return sx_response(await render_like_toggle_button(product_slug, liked))
@@ -257,7 +257,7 @@ def register():
from sx.sx_components import render_cart_added_response
item_data = getattr(g, "item_data", {})
d = item_data.get("d", {})
return sx_response(render_cart_added_response(g.cart, ci_ns, d))
return sx_response(await render_cart_added_response(g.cart, ci_ns, d))
# normal POST: go to cart page
from shared.infrastructure.urls import cart_url

File diff suppressed because it is too large Load Diff

View File

@@ -27,55 +27,55 @@ def _register_market_layouts() -> None:
register_custom_layout("market-admin", _market_admin_full, _market_admin_oob)
def _market_full(ctx: dict, **kw: Any) -> str:
async 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) + ")"
root_hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + ")"
return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
def _market_oob(ctx: dict, **kw: Any) -> str:
async 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 = await oob_header_sx("post-header-child", "market-header-child",
await _market_header_sx(ctx))
oobs = "(<> " + oobs + " " + await _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:
async def _market_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _mobile_nav_panel_sx
return _mobile_nav_panel_sx(ctx)
return await _mobile_nav_panel_sx(ctx)
def _market_admin_full(ctx: dict, **kw: Any) -> str:
async 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) + ")"
root_hdr = await root_header_sx(ctx)
child = "(<> " + await _post_header_sx(ctx) + " " + await _market_header_sx(ctx) + " "
child += await _market_admin_header_sx(ctx, selected=selected) + ")"
return "(<> " + root_hdr + " " + await header_child_sx(child) + ")"
def _market_admin_oob(ctx: dict, **kw: Any) -> str:
async 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 = "(<> " + await _market_header_sx(ctx, oob=True) + " "
oobs += await oob_header_sx("market-header-child", "market-admin-header-child",
await _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") + ")"
@@ -98,73 +98,83 @@ def _register_market_helpers() -> None:
})
def _h_all_markets_content():
async def _h_all_markets_content(**kw):
from quart import g, url_for, request
from shared.utils import route_prefix
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
data = getattr(g, "all_markets_data", None)
if not data:
page = int(request.args.get("page", 1))
markets, has_more = await services.market.list_marketplaces(
g.s, page=page, per_page=20,
)
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}
if not markets:
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"]
return await _no_markets_sx()
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()
from sx.sx_components import _market_cards_sx, _markets_grid
cards = await _market_cards_sx(markets, page_info, page, has_more, next_url)
content = await _markets_grid(cards)
return "(<> " + content + " " + '(div :class "pb-8")' + ")"
def _h_page_markets_content():
from quart import g, url_for
async def _h_page_markets_content(slug=None, **kw):
from quart import g, url_for, request
from shared.utils import route_prefix
from shared.services.registry import services
data = getattr(g, "page_markets_data", None)
if not data:
post = g.post_data["post"]
page = int(request.args.get("page", 1))
markets, has_more = await services.market.list_marketplaces(
g.s, "page", post["id"], page=page, per_page=20,
)
post_slug = post.get("slug", "")
if not markets:
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", "")
return await _no_markets_sx("No markets for this page")
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")
from sx.sx_components import _market_cards_sx, _markets_grid
cards = await _market_cards_sx(markets, {}, page, has_more, next_url,
show_page_badge=False, post_slug=post_slug)
content = await _markets_grid(cards)
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", "")
async def _h_page_admin_content(slug=None, **kw):
from shared.sx.page import get_template_context
from sx.sx_components import _markets_admin_panel_sx
ctx = await get_template_context()
content = await _markets_admin_panel_sx(ctx)
return '(div :id "main-panel" ' + content + ')'
def _h_market_home_content():
async def _h_market_home_content(page_slug=None, market_slug=None, **kw):
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)
return await _market_landing_content_sx(post)
def _h_market_admin_content():
def _h_market_admin_content(page_slug=None, market_slug=None, **kw):
return '"market admin"'

View File

@@ -1,10 +1,10 @@
;; 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)
;; page-markets-index: /<slug>/ — markets for a single page
;; page-admin: /<slug>/admin/ — post-level admin for markets
;; market-home: /<page_slug>/<market_slug>/ — market landing page
;; market-admin: /<page_slug>/<market_slug>/admin/ — market admin
(defpage all-markets-index
:path "/"
@@ -13,25 +13,25 @@
:content (all-markets-content))
(defpage page-markets-index
:path "/"
:path "/<slug>/"
:auth :public
:layout :post
:content (page-markets-content))
(defpage page-admin
:path "/"
:path "/<slug>/admin/"
:auth :admin
:layout (:post-admin :selected "markets")
:content (page-admin-content))
(defpage market-home
:path "/"
:path "/<page_slug>/<market_slug>/"
:auth :public
:layout :market
:content (market-home-content))
(defpage market-admin
:path "/"
:path "/<page_slug>/<market_slug>/admin/"
:auth :admin
:layout (:market-admin :selected "markets")
:content (market-admin-content))

View File

@@ -14,7 +14,6 @@ from bp import (
register_orders,
register_order,
register_checkout,
register_fragments,
register_actions,
register_data,
)
@@ -77,16 +76,19 @@ def create_app() -> "Quart":
from sxc.pages import setup_orders_pages
setup_orders_pages()
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "orders")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# Orders list at / (defpage routes mounted below)
# Orders list at /
bp = register_orders(url_prefix="/")
from shared.sx.pages import mount_pages
mount_pages(bp, "orders")
app.register_blueprint(bp)
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "orders")
# Checkout webhook + return
app.register_blueprint(register_checkout())

View File

@@ -3,4 +3,3 @@ from .orders.routes import register as register_orders
from .checkout.routes import register as register_checkout
from .data.routes import register as register_data
from .actions.routes import register as register_actions
from .fragments.routes import register as register_fragments

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