Merge branch 'macros' into worktree-sx-meta-eval

This commit is contained in:
2026-03-05 10:03:15 +00:00
301 changed files with 22850 additions and 18171 deletions

View File

@@ -108,6 +108,26 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
- Silent SSO: `prompt=none` OAuth flow for automatic cross-app login - Silent SSO: `prompt=none` OAuth flow for automatic cross-app login
- ActivityPub: RSA signatures, per-app virtual actor projections sharing same keypair - ActivityPub: RSA signatures, per-app virtual actor projections sharing same keypair
### SX Rendering Pipeline
The SX system renders component trees defined in s-expressions. The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn:
- `render_to_html(name, **kw)` — server-side, produces HTML. Used by route handlers returning full HTML.
- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Component calls stay **unexpanded** (serialized for client-side rendering by sx.js).
- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands the top-level component** then serializes children as SX wire format. Used by layout components that need Python context (auth state, fragments, URLs) resolved server-side.
- `sx_page(ctx, page_sx)` — produces the full HTML shell (`<!doctype html>...`) with component definitions, CSS, and page SX inlined for client-side boot.
See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table.
### Service SX Directory Convention
Each service has two SX-related directories:
- **`{service}/sx/`** — service-specific component definitions (`.sx` files with `defcomp`). Loaded at startup by `load_service_components()`. These define layout components, reusable UI fragments, etc.
- **`{service}/sxc/`** — page definitions and Python rendering logic. Contains `defpage` definitions (client-routed pages) and the Python functions that compose headers, layouts, and page content.
Shared components live in `shared/sx/templates/` and are loaded by `load_shared_components()` in the app factory.
### Art DAG ### Art DAG
- **3-Phase Execution:** Analyze → Plan → Execute (tasks in `artdag/l1/tasks/`) - **3-Phase Execution:** Analyze → Plan → Execute (tasks in `artdag/l1/tasks/`)
@@ -130,6 +150,10 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
| likes | (internal only) | 8009 | | likes | (internal only) | 8009 |
| orders | orders.rose-ash.com | 8010 | | orders | orders.rose-ash.com | 8010 |
## Dev Container Mounts
Dev bind mounts in `docker-compose.dev.yml` must mirror the Docker image's COPY paths. When adding a new directory to a service (e.g. `{service}/sx/`), add a corresponding volume mount (`./service/sx:/app/sx`) or the directory won't be visible inside the dev container. Hypercorn `--reload` watches for Python file changes; `.sx` file hot-reload is handled by `reload_if_changed()` in `shared/sx/jinja_bridge.py`.
## Key Config Files ## Key Config Files
- `docker-compose.yml` / `docker-compose.dev.yml` — service definitions, env vars, volumes - `docker-compose.yml` / `docker-compose.dev.yml` — service definitions, env vars, volumes

4
account/actions.sx Normal file
View File

@@ -0,0 +1,4 @@
;; Account service — inter-service action endpoints
;;
;; ghost-sync-member and ghost-push-member use local service imports —
;; remain as Python fallbacks.

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path from pathlib import Path
from quart import g, request from quart import g, request
@@ -8,7 +7,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app 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: async def account_context() -> dict:
@@ -72,8 +71,9 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# Setup defpage routes # Load .sx component files and setup defpage routes
import sx.sx_components # noqa: F811 — ensure components loaded from shared.sx.jinja_bridge import load_service_components
load_service_components(str(Path(__file__).resolve().parent), service_name="account")
from sxc.pages import setup_account_pages from sxc.pages import setup_account_pages
setup_account_pages() setup_account_pages()
@@ -81,11 +81,13 @@ def create_app() -> "Quart":
app.register_blueprint(register_auth_bp()) app.register_blueprint(register_auth_bp())
account_bp = register_account_bp() account_bp = register_account_bp()
from shared.sx.pages import mount_pages
mount_pages(account_bp, "account")
app.register_blueprint(account_bp) app.register_blueprint(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 from bp.actions.routes import register as register_actions
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())

View File

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

View File

@@ -7,17 +7,13 @@ from __future__ import annotations
from quart import ( from quart import (
Blueprint, Blueprint,
request,
redirect,
g, g,
) )
from sqlalchemy import select from sqlalchemy import select
from shared.models import UserNewsletter from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.urls import login_url from shared.sx.helpers import sx_response, sx_call
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.sx.helpers import sx_response
def register(url_prefix="/"): def register(url_prefix="/"):
@@ -25,8 +21,7 @@ def register(url_prefix="/"):
@account_bp.before_request @account_bp.before_request
async def _prepare_page_data(): async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes.""" """Fetch account_nav fragments for layout."""
# Fetch account nav items for layout (was in context_processor)
events_nav, cart_nav, artdag_nav = await fetch_fragments([ events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}), ("events", "account-nav-item", {}),
("cart", "account-nav-item", {}), ("cart", "account-nav-item", {}),
@@ -34,48 +29,6 @@ def register(url_prefix="/"):
], required=False) ], required=False)
g.account_nav = events_nav + cart_nav + artdag_nav 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/") @account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int): async def toggle_newsletter(newsletter_id: int):
if not g.get("user"): if not g.get("user"):
@@ -101,7 +54,26 @@ def register(url_prefix="/"):
await g.s.flush() await g.s.flush()
from sx.sx_components import render_newsletter_toggle # Render toggle directly — no sx_components intermediary
return sx_response(render_newsletter_toggle(un)) from shared.browser.app.csrf import generate_csrf_token
from shared.infrastructure.urls import account_url
nid = un.newsletter_id
url_fn = getattr(g, "_account_url", None) or account_url
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
translate = "translate-x-6" if un.subscribed else "translate-x-1"
checked = "true" if un.subscribed else "false"
return sx_response(sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs={"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}",
))
return account_bp return account_bp

View File

@@ -1,63 +1,33 @@
"""Account app action endpoints. """Account app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for All actions remain as Python fallbacks (local service imports).
cross-app callers (blog webhooks) via the internal action client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, request
from shared.infrastructure.actions import ACTION_HEADER from shared.infrastructure.query_blueprint import create_action_blueprint
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("account")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- ghost-sync-member ---
async def _ghost_sync_member(): async def _ghost_sync_member():
"""Sync a single Ghost member into db_account."""
data = await request.get_json() data = await request.get_json()
ghost_id = data.get("ghost_id") ghost_id = data.get("ghost_id")
if not ghost_id: if not ghost_id:
return {"error": "ghost_id required"}, 400 return {"error": "ghost_id required"}, 400
from services.ghost_membership import sync_single_member from services.ghost_membership import sync_single_member
await sync_single_member(g.s, ghost_id) await sync_single_member(g.s, ghost_id)
return {"ok": True} return {"ok": True}
_handlers["ghost-sync-member"] = _ghost_sync_member _handlers["ghost-sync-member"] = _ghost_sync_member
# --- ghost-push-member ---
async def _ghost_push_member(): async def _ghost_push_member():
"""Push a local user's membership data to Ghost."""
data = await request.get_json() data = await request.get_json()
user_id = data.get("user_id") user_id = data.get("user_id")
if not user_id: if not user_id:
return {"error": "user_id required"}, 400 return {"error": "user_id required"}, 400
from services.ghost_membership import sync_member_to_ghost from services.ghost_membership import sync_member_to_ghost
result_id = await sync_member_to_ghost(g.s, int(user_id)) result_id = await sync_member_to_ghost(g.s, int(user_id))
return {"ok": True, "ghost_id": result_id} return {"ok": True, "ghost_id": result_id}

View File

@@ -44,6 +44,17 @@ from .services import (
SESSION_USER_KEY = "uid" SESSION_USER_KEY = "uid"
ACCOUNT_SESSION_KEY = "account_sid" ACCOUNT_SESSION_KEY = "account_sid"
async def _render_auth_page(component: str, title: str, **kwargs) -> str:
"""Render an auth page with root layout — replaces sx_components helpers."""
from shared.sx.helpers import sx_call, full_page_sx, root_header_sx
from shared.sx.page import get_template_context
ctx = await get_template_context()
hdr = await root_header_sx(ctx)
content = sx_call(component, **{k: v for k, v in kwargs.items() if v})
return await full_page_sx(ctx, header_rows=hdr, content=content,
meta_html=f"<title>{title}</title>")
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"} ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
@@ -275,10 +286,7 @@ def register(url_prefix="/auth"):
redirect_url = pop_login_redirect_target() redirect_url = pop_login_redirect_target()
return redirect(redirect_url) return redirect(redirect_url)
from shared.sx.page import get_template_context return await _render_auth_page("account-login-content", "Login \u2014 Rose Ash")
from sx.sx_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
@rate_limit( @rate_limit(
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr), key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
@@ -291,20 +299,20 @@ def register(url_prefix="/auth"):
is_valid, email = validate_email(email_input) is_valid, email = validate_email(email_input)
if not is_valid: if not is_valid:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_login_page "account-login-content", "Login \u2014 Rose Ash",
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input) error="Please enter a valid email address.", email=email_input,
return await render_login_page(ctx), 400 ), 400
# Per-email rate limit: 5 magic links per 15 minutes # Per-email rate limit: 5 magic links per 15 minutes
from shared.infrastructure.rate_limit import _check_rate_limit from shared.infrastructure.rate_limit import _check_rate_limit
try: try:
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900) allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
if not allowed: if not allowed:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_check_email_page "account-check-email-content", "Check your email \u2014 Rose Ash",
ctx = await get_template_context(email=email, email_error=None) email=email,
return await render_check_email_page(ctx), 200 ), 200
except Exception: except Exception:
pass # Redis down — allow the request pass # Redis down — allow the request
@@ -324,10 +332,10 @@ def register(url_prefix="/auth"):
"Please try again in a moment." "Please try again in a moment."
) )
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_check_email_page "account-check-email-content", "Check your email \u2014 Rose Ash",
ctx = await get_template_context(email=email, email_error=email_error) email=email, email_error=email_error,
return await render_check_email_page(ctx) )
@auth_bp.get("/magic/<token>/") @auth_bp.get("/magic/<token>/")
async def magic(token: str): async def magic(token: str):
@@ -340,17 +348,17 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token) user, error = await validate_magic_link(s, token)
if error: if error:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_login_page "account-login-content", "Login \u2014 Rose Ash",
ctx = await get_template_context(error=error) error=error,
return await render_login_page(ctx), 400 ), 400
user_id = user.id user_id = user.id
except Exception: except Exception:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_login_page "account-login-content", "Login \u2014 Rose Ash",
ctx = await get_template_context(error="Could not sign you in right now. Please try again.") error="Could not sign you in right now. Please try again.",
return await render_login_page(ctx), 502 ), 502
assert user_id is not None assert user_id is not None
@@ -679,11 +687,11 @@ def register(url_prefix="/auth"):
@auth_bp.get("/device/") @auth_bp.get("/device/")
async def device_form(): async def device_form():
"""Browser form where user enters the code displayed in terminal.""" """Browser form where user enters the code displayed in terminal."""
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
code = request.args.get("code", "") code = request.args.get("code", "")
ctx = await get_template_context(code=code) return await _render_auth_page(
return await render_device_page(ctx) "account-device-content", "Authorize Device \u2014 Rose Ash",
code=code,
)
@auth_bp.post("/device") @auth_bp.post("/device")
@auth_bp.post("/device/") @auth_bp.post("/device/")
@@ -693,20 +701,20 @@ def register(url_prefix="/auth"):
user_code = (form.get("code") or "").strip().replace("-", "").upper() user_code = (form.get("code") or "").strip().replace("-", "").upper()
if not user_code or len(user_code) != 8: if not user_code or len(user_code) != 8:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_page "account-device-content", "Authorize Device \u2014 Rose Ash",
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", "")) error="Please enter a valid 8-character code.", code=form.get("code", ""),
return await render_device_page(ctx), 400 ), 400
from shared.infrastructure.auth_redis import get_auth_redis from shared.infrastructure.auth_redis import get_auth_redis
r = await get_auth_redis() r = await get_auth_redis()
device_code = await r.get(f"devflow_uc:{user_code}") device_code = await r.get(f"devflow_uc:{user_code}")
if not device_code: if not device_code:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_page "account-device-content", "Authorize Device \u2014 Rose Ash",
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", "")) error="Code not found or expired. Please try again.", code=form.get("code", ""),
return await render_device_page(ctx), 400 ), 400
if isinstance(device_code, bytes): if isinstance(device_code, bytes):
device_code = device_code.decode() device_code = device_code.decode()
@@ -720,23 +728,19 @@ def register(url_prefix="/auth"):
# Logged in — approve immediately # Logged in — approve immediately
ok = await _approve_device(device_code, g.user) ok = await _approve_device(device_code, g.user)
if not ok: if not ok:
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_page "account-device-content", "Authorize Device \u2014 Rose Ash",
ctx = await get_template_context(error="Code expired or already used.") error="Code expired or already used.",
return await render_device_page(ctx), 400 ), 400
from shared.sx.page import get_template_context return await _render_auth_page(
from sx.sx_components import render_device_approved_page "account-device-approved", "Device Authorized \u2014 Rose Ash",
ctx = await get_template_context() )
return await render_device_approved_page(ctx)
@auth_bp.get("/device/complete") @auth_bp.get("/device/complete")
@auth_bp.get("/device/complete/") @auth_bp.get("/device/complete/")
async def device_complete(): async def device_complete():
"""Post-login redirect — completes approval after magic link auth.""" """Post-login redirect — completes approval after magic link auth."""
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page, render_device_approved_page
device_code = request.args.get("code", "") device_code = request.args.get("code", "")
if not device_code: if not device_code:
@@ -748,12 +752,13 @@ def register(url_prefix="/auth"):
ok = await _approve_device(device_code, g.user) ok = await _approve_device(device_code, g.user)
if not ok: if not ok:
ctx = await get_template_context( return await _render_auth_page(
"account-device-content", "Authorize Device \u2014 Rose Ash",
error="Code expired or already used. Please start the login process again in your terminal.", error="Code expired or already used. Please start the login process again in your terminal.",
) ), 400
return await render_device_page(ctx), 400
ctx = await get_template_context() return await _render_auth_page(
return await render_device_approved_page(ctx) "account-device-approved", "Device Authorized \u2014 Rose Ash",
)
return auth_bp return auth_bp

View File

@@ -1,67 +1,14 @@
"""Account app data endpoints. """Account app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``account/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from sqlalchemy import select
from shared.models import User
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("account")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- user-by-email ---
async def _user_by_email():
"""Return user_id for a given email address."""
email = request.args.get("email", "").strip().lower()
if not email:
return None
result = await g.s.execute(
select(User.id).where(User.email.ilike(email))
)
row = result.first()
if not row:
return None
return {"user_id": row[0]}
_handlers["user-by-email"] = _user_by_email
# --- newsletters ---
async def _newsletters():
"""Return all Ghost newsletters (for blog post editor)."""
from shared.models.ghost_membership_entities import GhostNewsletter
result = await g.s.execute(
select(GhostNewsletter.id, GhostNewsletter.ghost_id, GhostNewsletter.name, GhostNewsletter.slug)
.order_by(GhostNewsletter.name)
)
return [
{"id": row[0], "ghost_id": row[1], "name": row[2], "slug": row[3]}
for row in result.all()
]
_handlers["newsletters"] = _newsletters
return bp return 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

9
account/queries.sx Normal file
View File

@@ -0,0 +1,9 @@
;; Account service — inter-service data queries
(defquery user-by-email (&key email)
"Return user_id for a given email address."
(service "account" "user-by-email" :email email))
(defquery newsletters ()
"Return all Ghost newsletters."
(service "account" "newsletters"))

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
def register_domain_services() -> None: def register_domain_services() -> None:
"""Register services for the account app. """Register services for the account app."""
from shared.services.registry import services
from .account_page import AccountPageService
services.register("account_page", AccountPageService())
Account is a consumer-only dashboard app. It has no own domain. from shared.services.account_impl import SqlAccountDataService
All cross-app data comes via fragments and HTTP data endpoints. services.register("account", SqlAccountDataService())
"""
pass

View File

@@ -0,0 +1,40 @@
"""Account page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
class AccountPageService:
"""Service for account page data, callable via (service "account-page" ...)."""
async def newsletters_data(self, session, **kw):
"""Return newsletter list with user subscription status."""
from quart import g
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
result = await session.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await session.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,
})
from shared.infrastructure.urls import account_url
return {
"newsletter_list": newsletter_list,
"account_url": account_url(""),
}

View File

@@ -27,3 +27,25 @@
(h1 :class "text-2xl font-bold mb-4" "Device authorized") (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."))) (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) name)
logout) logout)
labels))) 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

@@ -1,4 +1,5 @@
;; Account auth-menu fragment handler ;; Account auth-menu fragment handler
;; returns: sx
;; ;;
;; Renders the desktop + mobile auth menu (sign-in or user link). ;; Renders the desktop + mobile auth menu (sign-in or user link).

20
account/sx/layouts.sx Normal file
View File

@@ -0,0 +1,20 @@
;; Account layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout("account", ...) in __init__.py.
;; Full page: root header + auth header row in header-child
(defcomp ~account-layout-full ()
(<> (~root-header-auto)
(~header-child-sx
:inner (~auth-header-row-auto))))
;; OOB (HTMX): auth row + root header, both with oob=true
(defcomp ~account-layout-oob ()
(<> (~auth-header-row-auto true)
(~root-header-auto true)))
;; Mobile menu: auth section + root nav
(defcomp ~account-layout-mobile ()
(<> (~mobile-menu-section
:label "account" :href "/" :level 1 :colour "sky"
:items (~auth-nav-items-auto))
(~root-mobile-auto)))

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" (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") (h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
list))) 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 {: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,339 +0,0 @@
"""
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()``.
"""
from __future__ import annotations
import os
from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, sx_call, SxExpr,
root_header_sx, full_page_sx,
)
# Load account-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="account")
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
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) + ")"
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,
)
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) + ")"
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# ---------------------------------------------------------------------------
def _account_main_panel_sx(ctx: dict) -> str:
"""Account info panel with user details and logout."""
from quart import g
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
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
if un.subscribed:
bg = "bg-emerald-500"
translate = "translate-x-6"
checked = "true"
else:
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
return sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
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

@@ -1,13 +1,10 @@
"""Account defpage setup — registers layouts, page helpers, and loads .sx pages.""" """Account defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations from __future__ import annotations
from typing import Any
def setup_account_pages() -> None: def setup_account_pages() -> None:
"""Register account-specific layouts, page helpers, and load page definitions.""" """Register account-specific layouts and load page definitions."""
_register_account_layouts() _register_account_layouts()
_register_account_helpers()
_load_account_page_files() _load_account_page_files()
@@ -17,89 +14,6 @@ def _load_account_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "account") load_page_dir(os.path.dirname(__file__), "account")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_account_layouts() -> None: def _register_account_layouts() -> None:
from shared.sx.layouts import register_custom_layout from shared.sx.layouts import register_sx_layout
register_custom_layout("account", _account_full, _account_oob, _account_mobile) register_sx_layout("account", "account-layout-full", "account-layout-oob", "account-layout-mobile")
def _account_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _auth_header_sx
root_hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
return "(<> " + root_hdr + " " + hdr_child + ")"
def _account_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _auth_header_sx
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
def _account_mobile(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr
from sx.sx_components import _auth_nav_mobile_sx
ctx = _inject_account_nav(ctx)
auth_section = sx_call("mobile-menu-section",
label="account", href="/", level=1, colour="sky",
items=SxExpr(_auth_nav_mobile_sx(ctx)))
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
def _inject_account_nav(ctx: dict) -> dict:
"""Ensure account_nav is in ctx from g.account_nav."""
if "account_nav" not in ctx:
from quart import g
ctx = dict(ctx)
ctx["account_nav"] = getattr(g, "account_nav", "")
return ctx
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_account_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("account", {
"account-content": _h_account_content,
"newsletters-content": _h_newsletters_content,
"fragment-content": _h_fragment_content,
})
def _h_account_content():
from sx.sx_components import _account_main_panel_sx
return _account_main_panel_sx({})
def _h_newsletters_content():
from quart import g
d = getattr(g, "newsletters_data", None)
if not d:
from shared.sx.helpers import sx_call
return sx_call("account-newsletter-empty")
from shared.sx.page import get_template_context_sync
from sx.sx_components import _newsletters_panel_sx
# Build a minimal ctx with account_url
ctx = {"account_url": getattr(g, "_account_url", None)}
if ctx["account_url"] is None:
from shared.infrastructure.urls import account_url
ctx["account_url"] = account_url
return _newsletters_panel_sx(ctx, d)
def _h_fragment_content():
from quart import g
frag = getattr(g, "fragment_page_data", None)
if not frag:
return ""
from sx.sx_components import _fragment_content
return _fragment_content(frag)

View File

@@ -8,7 +8,7 @@
:path "/" :path "/"
:auth :login :auth :login
:layout :account :layout :account
:content (account-content)) :content (~account-dashboard-content))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Newsletters ;; Newsletters
@@ -18,7 +18,10 @@
:path "/newsletters/" :path "/newsletters/"
:auth :login :auth :login
:layout :account :layout :account
:content (newsletters-content)) :data (service "account-page" "newsletters-data")
:content (~account-newsletters-content
:newsletter-list newsletter-list
:account-url account-url))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Fragment pages (tickets, bookings, etc. from events service) ;; Fragment pages (tickets, bookings, etc. from events service)
@@ -28,4 +31,10 @@
:path "/<slug>/" :path "/<slug>/"
:auth :login :auth :login
:layout :account :layout :account
:content (fragment-content)) :content (let* ((user (current-user))
(result (frag "events" "account-page"
:slug slug
:user-id (str (get user "id")))))
(if (or (nil? result) (empty? result))
(abort 404)
result)))

12
blog/actions.sx Normal file
View File

@@ -0,0 +1,12 @@
;; Blog service — inter-service action endpoints
(defaction update-page-config (&key container-type container-id
features sumup-merchant-code
sumup-checkout-prefix sumup-api-key)
"Create or update a PageConfig with features and SumUp settings."
(service "page-config" "update"
:container-type container-type :container-id container-id
:features features
:sumup-merchant-code sumup-merchant-code
:sumup-checkout-prefix sumup-checkout-prefix
:sumup-api-key sumup-api-key))

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path from pathlib import Path
from quart import g, request from quart import g, request
@@ -16,7 +15,6 @@ from bp import (
register_admin, register_admin,
register_menu_items, register_menu_items,
register_snippets, register_snippets,
register_fragments,
register_data, register_data,
register_actions, register_actions,
) )
@@ -108,7 +106,9 @@ def create_app() -> "Quart":
app.register_blueprint(register_admin("/settings")) app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items()) app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets()) 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_data())
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
@@ -162,6 +162,23 @@ def create_app() -> "Quart":
) )
return jsonify(resp) 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 --- # --- debug: url rules ---
@app.get("/__rules") @app.get("/__rules")
async def dump_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 .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets from .snippets.routes import register as register_snippets
from .fragments import register_fragments
from .data import register_data from .data import register_data
from .actions.routes import register as register_actions from .actions.routes import register as register_actions

View File

@@ -1,96 +1,14 @@
"""Blog app action endpoints. """Blog app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for All actions are defined in ``blog/actions.sx``.
cross-app callers via the internal action client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.actions import ACTION_HEADER from shared.infrastructure.query_blueprint import create_action_blueprint
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("blog")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
result = await handler()
return jsonify(result or {"ok": True})
# --- update-page-config ---
async def _update_page_config():
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
from shared.models.page_config import PageConfig
from sqlalchemy import select
from sqlalchemy.orm.attributes import flag_modified
data = await request.get_json(force=True)
container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "features" in data:
features = dict(pc.features or {})
for key, val in data["features"].items():
if isinstance(val, bool):
features[key] = val
elif val in ("true", "1", "on"):
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
pc.features = features
flag_modified(pc, "features")
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
_handlers["update-page-config"] = _update_page_config
return bp return bp

View File

@@ -3,13 +3,9 @@ from __future__ import annotations
#from quart import Blueprint, g #from quart import Blueprint, g
from quart import ( from quart import (
render_template,
make_response,
Blueprint, Blueprint,
redirect, redirect,
url_for, url_for,
request,
jsonify
) )
from shared.browser.app.redis_cacher import clear_all_cache from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
@@ -27,23 +23,6 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings", "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/") @bp.post("/cache_clear/")
@require_admin @require_admin
async def cache_clear(): async def cache_clear():
@@ -54,7 +33,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S")) html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html) return sx_response(html)
return redirect(url_for("settings.defpage_cache_page")) return redirect(url_for("defpage_cache_page"))
return bp return bp

View File

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

View File

@@ -21,7 +21,7 @@ from .services.pages_data import pages_data
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response, sx_call
from shared.utils import host_url from shared.utils import host_url
def register(url_prefix, title): def register(url_prefix, title):
@@ -53,16 +53,6 @@ def register(url_prefix, title):
@blogs_bp.before_request @blogs_bp.before_request
async def route(): async def route():
g.makeqs_factory = makeqs_factory g.makeqs_factory = makeqs_factory
ep = request.endpoint or ""
if "defpage_new_post" in ep:
from sx.sx_components import render_editor_panel
g.editor_content = render_editor_panel()
elif "defpage_new_page" in ep:
from sx.sx_components import render_editor_panel
g.editor_page_content = render_editor_panel(is_page=True)
from shared.sx.pages import mount_pages
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
@blogs_bp.context_processor @blogs_bp.context_processor
async def inject_root(): async def inject_root():
@@ -72,6 +62,19 @@ def register(url_prefix, title):
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""), "unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
} }
async def _render_new_post_page(tctx):
"""Compose a full page with blog header for new post/page creation."""
from shared.sx.helpers import root_header_sx, full_page_sx
from shared.sx.parser import SxExpr
root_hdr = await root_header_sx(tctx)
blog_hdr = sx_call("menu-row-sx",
id="blog-row", level=1,
link_label_content=SxExpr("(div)"),
child_id="blog-header-child")
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
content = tctx.get("editor_html", "")
return await full_page_sx(tctx, header_rows=header_rows, content=content)
SORT_MAP = { SORT_MAP = {
"newest": "published_at DESC", "newest": "published_at DESC",
"oldest": "published_at ASC", "oldest": "published_at ASC",
@@ -128,100 +131,83 @@ def register(url_prefix, title):
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total) ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_home_page, render_home_oob from shared.sx.helpers import (
sx_call, root_header_sx, full_page_sx, oob_page_sx,
post_header_sx, oob_header_sx, mobile_menu_sx,
post_mobile_nav_sx, mobile_root_nav_sx,
)
from shared.sx.parser import SxExpr
from shared.services.registry import services
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(ctx) tctx.update(ctx)
post = ctx.get("post", {})
content = sx_call("blog-home-main",
html_content=post.get("html", ""),
sx_content=SxExpr(post.get("sx_content", "")) if post.get("sx_content") else None)
meta_data = services.blog_page.post_meta_data(post, ctx.get("base_title", ""))
meta = sx_call("blog-meta", **meta_data)
if not is_htmx_request(): if not is_htmx_request():
html = await render_home_page(tctx) root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
return await make_response(html) return await make_response(html)
else: else:
sx_src = await render_home_oob(tctx) root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
sx_src = await oob_page_sx(oobs=header_oob, content=content)
return sx_response(sx_src) return sx_response(sx_src)
@blogs_bp.get("/index") @blogs_bp.get("/index")
@blogs_bp.get("/index/") @blogs_bp.get("/index/")
async def index(): async def index():
"""Blog listing — moved from / to /index.""" """Blog listing — moved from / to /index."""
from shared.services.registry import services
q = decode() from shared.sx.helpers import (
content_type = request.args.get("type", "posts") sx_call, root_header_sx, full_page_sx, oob_page_sx, oob_header_sx,
if content_type == "pages":
data = await pages_data(g.s, q.page, q.search)
context = {
**data,
"content_type": "pages",
"search": q.search,
"selected_tags": (),
"selected_authors": (),
"selected_groups": (),
"sort": None,
"view": None,
"drafts": None,
"draft_count": 0,
"tags": [],
"authors": [],
"tag_groups": [],
"posts": data.get("pages", []),
}
from shared.sx.page import get_template_context
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_page_cards
tctx = await get_template_context()
tctx.update(context)
if not is_htmx_request():
html = await render_blog_page(tctx)
return await make_response(html)
elif q.page > 1:
sx_src = await render_blog_page_cards(tctx)
return sx_response(sx_src)
else:
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
# Default: posts listing
# Drafts filter requires login; ignore if not logged in
show_drafts = bool(q.drafts and g.user)
is_admin = bool((g.get("rights") or {}).get("admin"))
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
# For the draft count badge: admin sees all drafts, non-admin sees own
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
data = await posts_data(
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
drafts=show_drafts, drafts_user_id=drafts_user_id,
count_drafts_for_user_id=count_drafts_uid,
selected_groups=q.selected_groups,
) )
from shared.sx.parser import SxExpr
context = { def _blog_hdr(ctx, oob=False):
**data, return sx_call("menu-row-sx",
"content_type": "posts", id="blog-row", level=1,
"selected_tags": q.selected_tags, link_label_content=SxExpr("(div)"),
"selected_authors": q.selected_authors, child_id="blog-header-child", oob=oob)
"selected_groups": q.selected_groups,
"sort": q.sort, data = await services.blog_page.index_data(g.s)
"search": q.search,
"view": q.view, # Render content, aside, and filter via .sx defcomps
"drafts": q.drafts if show_drafts else None, content = sx_call("blog-index-main-content", **data)
} aside = sx_call("blog-index-aside-content", **data)
filter_sx = sx_call("blog-index-filter-content", **data)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_blog_page, render_blog_oob, render_blog_cards
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(context)
if not is_htmx_request(): if not is_htmx_request():
html = await render_blog_page(tctx) root_hdr = await root_header_sx(tctx)
blog_hdr = _blog_hdr(tctx)
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
html = await full_page_sx(tctx, header_rows=header_rows,
content=content, aside=aside, filter=filter_sx)
return await make_response(html) return await make_response(html)
elif q.page > 1: elif data.get("page", 1) > 1:
# Sx wire format — client renders blog cards # Pagination — return just the cards
sx_src = await render_blog_cards(tctx) return sx_response(content)
return sx_response(sx_src)
else: else:
sx_src = await render_blog_oob(tctx) root_hdr = await root_header_sx(tctx)
blog_hdr = _blog_hdr(tctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = await oob_header_sx("root-header-child", "blog-header-child", rows)
sx_src = await oob_page_sx(oobs=header_oob, content=content,
aside=aside, filter=filter_sx)
return sx_response(sx_src) return sx_response(sx_src)
@blogs_bp.post("/new/") @blogs_bp.post("/new/")
@@ -243,19 +229,19 @@ def register(url_prefix, title):
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sxc.pages.renders import render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.") tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx) html = await _render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc) ok, reason = validate_lexical(lexical_doc)
if not ok: if not ok:
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sxc.pages.renders import render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason) tctx["editor_html"] = render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx) html = await _render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
# Create directly in db_blog # Create directly in db_blog
@@ -277,7 +263,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the edit page # Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug))) return redirect(host_url(url_for("defpage_post_edit", slug=post.slug)))
@blogs_bp.post("/new-page/") @blogs_bp.post("/new-page/")
@@ -299,21 +285,21 @@ def register(url_prefix, title):
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sxc.pages.renders import render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True) tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["is_page"] = True tctx["is_page"] = True
html = await render_new_post_page(tctx) html = await _render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc) ok, reason = validate_lexical(lexical_doc)
if not ok: if not ok:
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel from sxc.pages.renders import render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True) tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
tctx["is_page"] = True tctx["is_page"] = True
html = await render_new_post_page(tctx) html = await _render_new_post_page(tctx)
return await make_response(html, 400) return await make_response(html, 400)
# Create directly in db_blog # Create directly in db_blog
@@ -335,7 +321,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog") await invalidate_tag_cache("blog")
# Redirect to the page admin # Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug))) return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/") @blogs_bp.get("/drafts/")

View File

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

View File

@@ -1,185 +1,14 @@
"""Blog app data endpoints. """Blog app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``blog/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from shared.contracts.dtos import dto_to_dict
from services import blog_service
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("blog")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- post-by-slug ---
async def _post_by_slug():
slug = request.args.get("slug", "")
post = await blog_service.get_post_by_slug(g.s, slug)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-slug"] = _post_by_slug
# --- post-by-id ---
async def _post_by_id():
post_id = int(request.args.get("id", 0))
post = await blog_service.get_post_by_id(g.s, post_id)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-id"] = _post_by_id
# --- posts-by-ids ---
async def _posts_by_ids():
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
posts = await blog_service.get_posts_by_ids(g.s, ids)
return [dto_to_dict(p) for p in posts]
_handlers["posts-by-ids"] = _posts_by_ids
# --- search-posts ---
async def _search_posts():
query = request.args.get("query", "")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
posts, total = await blog_service.search_posts(g.s, query, page, per_page)
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
_handlers["search-posts"] = _search_posts
# --- page-config-ensure ---
async def _page_config_ensure():
"""Get or create a PageConfig for a container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
container_type = request.args.get("container_type", "page")
container_id = request.args.get("container_id", type=int)
if container_id is None:
return {"error": "container_id required"}, 400
row = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if row is None:
row = PageConfig(
container_type=container_type,
container_id=container_id,
features={},
)
g.s.add(row)
await g.s.flush()
return {
"id": row.id,
"container_type": row.container_type,
"container_id": row.container_id,
}
_handlers["page-config-ensure"] = _page_config_ensure
# --- page-config ---
async def _page_config():
"""Return a single PageConfig by container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
cid = request.args.get("container_id", type=int)
if cid is None:
return None
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id == cid,
)
)).scalar_one_or_none()
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config"] = _page_config
# --- page-config-by-id ---
async def _page_config_by_id():
"""Return a single PageConfig by its primary key."""
from shared.models.page_config import PageConfig
pc_id = request.args.get("id", type=int)
if pc_id is None:
return None
pc = await g.s.get(PageConfig, pc_id)
if not pc:
return None
return _page_config_dict(pc)
_handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch ---
async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated)."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
ct = request.args.get("container_type", "page")
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
if not ids:
return []
result = await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == ct,
PageConfig.container_id.in_(ids),
)
)
return [_page_config_dict(pc) for pc in result.scalars().all()]
_handlers["page-configs-batch"] = _page_configs_batch
return bp return bp
def _page_config_dict(pc) -> dict:
"""Serialize PageConfig to a JSON-safe dict."""
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}

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

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, make_response, request, jsonify, g from quart import Blueprint, make_response, request, jsonify, g, url_for
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from .services.menu_items import ( from .services.menu_items import (
@@ -12,37 +12,217 @@ from .services.menu_items import (
search_pages, search_pages,
MenuItemError, MenuItemError,
) )
from shared.browser.app.utils.htmx import is_htmx_request from markupsafe import escape
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response, sx_call
from shared.sx.parser import SxExpr
from shared.browser.app.csrf import generate_csrf_token
def _render_menu_items_list(menu_items):
"""Serialize ORM menu items and render via .sx defcomp."""
csrf = generate_csrf_token()
items = []
for item in menu_items:
items.append({
"feature_image": getattr(item, "feature_image", None),
"label": getattr(item, "label", "") or "",
"url": getattr(item, "url", "") or "",
"sort_order": getattr(item, "position", 0) or 0,
"edit_url": url_for("menu_items.edit_menu_item", item_id=item.id),
"delete_url": url_for("menu_items.delete_menu_item_route", item_id=item.id),
})
new_url = url_for("menu_items.new_menu_item")
return sx_call("blog-menu-items-content",
menu_items=items, new_url=new_url, csrf=csrf)
def _render_menu_item_form(menu_item=None) -> str:
"""Render menu item add/edit form."""
csrf = generate_csrf_token()
search_url = url_for("menu_items.search_pages_route")
is_edit = menu_item is not None
if is_edit:
action_url = url_for("menu_items.update_menu_item_route", item_id=menu_item.id)
action_attr = f'sx-put="{action_url}"'
post_id = str(menu_item.container_id) if menu_item.container_id else ""
label = getattr(menu_item, "label", "") or ""
slug = getattr(menu_item, "slug", "") or ""
fi = getattr(menu_item, "feature_image", None) or ""
else:
action_url = url_for("menu_items.create_menu_item_route")
action_attr = f'sx-post="{action_url}"'
post_id = ""
label = ""
slug = ""
fi = ""
if post_id:
img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
else:
selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'
close_js = "document.getElementById('menu-item-form').innerHTML = ''"
title = "Edit Menu Item" if is_edit else "Add Menu Item"
html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">{title}</h2>
<button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
<i class="fa fa-times"></i></button>
</div>
<input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
{selected}
<form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
sx-include="#selected-post-id"
sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
class="space-y-4">
<input type="hidden" name="csrf_token" value="{csrf}">
<div class="flex gap-2 pb-3 border-b">
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fa fa-save"></i> Save</button>
<button type="button" onclick="{close_js}"
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
</div>
</form>
<div class="mt-4">
<label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
<input type="text" placeholder="Search for a page... (or leave blank for all)"
sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
sx-target="#page-search-results" sx-swap="innerHTML"
name="q" id="page-search-input"
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div id="page-search-results" class="mt-2"></div>
</div>
</div>
<script>
document.addEventListener('click', function(e) {{
var pageOption = e.target.closest('[data-page-id]');
if (pageOption) {{
var postId = pageOption.dataset.pageId;
var postTitle = pageOption.dataset.pageTitle;
var postSlug = pageOption.dataset.pageSlug;
var postImage = pageOption.dataset.pageImage;
document.getElementById('selected-post-id').value = postId;
var display = document.getElementById('selected-page-display');
display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
(postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
'<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
display.classList.remove('hidden');
document.getElementById('page-search-results').innerHTML = '';
}}
}});
</script>'''
return html
def _render_page_search_results(pages, query, page, has_more) -> str:
"""Render page search results."""
if not pages and query:
return sx_call("page-search-empty", query=query)
if not pages:
return ""
items = []
for post in pages:
items.append(sx_call("page-search-item",
id=post.id, title=post.title,
slug=post.slug,
feature_image=post.feature_image or None))
sentinel = ""
if has_more:
search_url = url_for("menu_items.search_pages_route")
sentinel = sx_call("page-search-sentinel",
url=search_url, query=query,
next_page=page + 1)
items_sx = "(<> " + " ".join(items) + ")"
return sx_call("page-search-results",
items=SxExpr(items_sx),
sentinel=sentinel or None)
def _render_menu_items_nav_oob(menu_items) -> str:
"""Render OOB nav update for menu items."""
from quart import request as qrequest
if not menu_items:
return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_button_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-3"
)
container_id = "menu-items-container"
arrow_cls = f"scrolling-menu-arrow-{container_id}"
scroll_hs = (
f"on load or scroll"
f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}"
f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end"
)
item_parts = []
for item in menu_items:
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
href = f"/{item_slug}/"
selected = "true" if item_slug == first_seg else "false"
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
if item_slug != "cart":
item_parts.append(sx_call("blog-nav-item-link",
href=href, hx_get=f"/{item_slug}/", selected=selected,
nav_cls=nav_button_cls, img=img_sx, label=label,
))
else:
item_parts.append(sx_call("blog-nav-item-plain",
href=href, selected=selected, nav_cls=nav_button_cls,
img=img_sx, label=label,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("scroll-nav-wrapper",
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
arrow_cls=arrow_cls,
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
def register(): def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items') bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
def get_menu_items_nav_oob_sync(menu_items): def get_menu_items_nav_oob_sync(menu_items):
"""Helper to generate OOB update for root nav 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)
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"])
@bp.get("/new/") @bp.get("/new/")
@require_admin @require_admin
async def new_menu_item(): async def new_menu_item():
"""Show form to create new menu item""" """Show form to create new menu item"""
from sx.sx_components import render_menu_item_form return sx_response(_render_menu_item_form())
return sx_response(render_menu_item_form())
@bp.post("/") @bp.post("/")
@require_admin @require_admin
@@ -65,8 +245,7 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) 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)
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
@@ -81,8 +260,7 @@ def register():
if not menu_item: if not menu_item:
return await make_response("Menu item not found", 404) return await make_response("Menu item not found", 404)
from sx.sx_components import render_menu_item_form return sx_response(_render_menu_item_form(menu_item=menu_item))
return sx_response(render_menu_item_form(menu_item=menu_item))
@bp.put("/<int:item_id>/") @bp.put("/<int:item_id>/")
@require_admin @require_admin
@@ -105,8 +283,7 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) 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)
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
@@ -126,8 +303,7 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) 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)
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)
@@ -142,8 +318,7 @@ def register():
pages, total = await search_pages(g.s, query, page, per_page) pages, total = await search_pages(g.s, query, page, per_page)
has_more = (page * per_page) < total 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(render_page_search_results(pages, query, page, has_more))
@bp.post("/reorder/") @bp.post("/reorder/")
@require_admin @require_admin
@@ -167,8 +342,7 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) 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)
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return sx_response(html + nav_oob) return sx_response(html + nav_oob)

View File

@@ -10,10 +10,18 @@ from quart import (
url_for, url_for,
) )
from shared.browser.app.authz import require_admin, require_post_author from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request from markupsafe import escape
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response, sx_call
from shared.sx.parser import SxExpr, serialize as sx_serialize
from shared.utils import host_url from shared.utils import host_url
def _raw_html_sx(html: str) -> str:
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
if not html:
return ""
return "(raw! " + sx_serialize(html) + ")"
def _post_to_edit_dict(post) -> dict: def _post_to_edit_dict(post) -> dict:
"""Convert an ORM Post to a dict matching the shape templates expect. """Convert an ORM Post to a dict matching the shape templates expect.
@@ -52,158 +60,225 @@ def _post_to_edit_dict(post) -> dict:
return d return d
def _render_features(features, post, result):
"""Render features panel via .sx defcomp."""
slug = post.get("slug", "")
return sx_call("blog-features-panel-content",
features_url=host_url(url_for("blog.post.admin.update_features", slug=slug)),
calendar_checked=bool(features.get("calendar")),
market_checked=bool(features.get("market")),
show_sumup=bool(features.get("calendar") or features.get("market")),
sumup_url=host_url(url_for("blog.post.admin.update_sumup", slug=slug)),
merchant_code=result.get("sumup_merchant_code") or "",
placeholder="\u2022" * 8 if result.get("sumup_configured") else "sup_sk_...",
sumup_configured=result.get("sumup_configured", False),
checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
def _serialize_markets(markets, slug):
"""Serialize ORM/DTO market objects to dicts for .sx defcomp."""
result = []
for m in markets:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
result.append({
"name": m_name, "slug": m_slug,
"delete_url": host_url(url_for("blog.post.admin.delete_market",
slug=slug, market_slug=m_slug)),
})
return result
def _render_calendar_view(
calendar, year, month, month_name, weekday_names, weeks,
prev_month, prev_month_year, next_month, next_month_year,
prev_year, next_year, month_entries, associated_entry_ids,
post_slug: str,
) -> str:
"""Build calendar month grid HTML."""
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
esc = escape
csrf = generate_csrf_token()
cal_id = calendar.id
def cal_url(y, m):
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
cur_url = cal_url(year, month)
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
nav = (
f'<header class="flex items-center justify-center mb-4">'
f'<nav class="flex items-center gap-2 text-xl">'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&laquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&lsaquo;</a>'
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&rsaquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&raquo;</a>'
f'</nav></header>'
)
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
cells: list[str] = []
for week in weeks:
for day in week:
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
day_date = day.date
entry_btns: list[str] = []
for e in month_entries:
e_start = getattr(e, "start_at", None)
if not e_start or e_start.date() != day_date:
continue
e_id = getattr(e, "id", None)
e_name = esc(getattr(e, "name", ""))
t_url = toggle_url_fn(e_id)
hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
if e_id in associated_entry_ids:
entry_btns.append(
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
f'<span class="truncate flex-1">{e_name}</span>'
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
f' data-confirm data-confirm-title="Remove entry?"'
f' data-confirm-text="Remove {e_name} from this post?"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><i class="fa fa-times"></i></button></div>'
)
else:
entry_btns.append(
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
f' data-confirm data-confirm-title="Add entry?"'
f' data-confirm-text="Add {e_name} to this post?"'
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><span class="truncate block">{e_name}</span></button>'
)
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
cells.append(
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
)
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
html = (
f'<div id="calendar-view-{cal_id}"'
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
f'{nav}'
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
f'</div>'
)
return _raw_html_sx(html)
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
"""Render the associated entries panel."""
from shared.browser.app.csrf import generate_csrf_token
from sxc.pages.helpers import _extract_associated_entries_data
csrf = generate_csrf_token()
entry_data = _extract_associated_entries_data(
all_calendars, associated_entry_ids, post_slug)
return sx_call("blog-associated-entries-from-data",
entries=entry_data, csrf=csrf)
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
"""Render the OOB nav entries swap."""
entries_list = []
if associated_entries and hasattr(associated_entries, "entries"):
entries_list = associated_entries.entries or []
has_items = bool(entries_list or calendars)
if not has_items:
return sx_call("blog-nav-entries-empty")
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-2"
)
post_slug = post.get("slug", "")
scroll_hs = (
"on load or scroll"
" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
" remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow"
" else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"
)
item_parts = []
for entry in entries_list:
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
cal_slug = getattr(entry, "calendar_slug", "")
if e_start:
entry_path = (
f"/{post_slug}/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
)
date_str = e_start.strftime("%b %d, %Y at %H:%M")
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
entry_path = f"/{post_slug}/{cal_slug}/"
date_str = ""
item_parts.append(sx_call("calendar-entry-nav",
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
))
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
cal_path = f"/{post_slug}/{cal_slug}/"
item_parts.append(sx_call("blog-nav-calendar-item",
href=cal_path, nav_cls=nav_cls, name=cal_name,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("scroll-nav-wrapper",
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
arrow_cls="entries-nav-arrow",
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') 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/") @bp.put("/features/")
@require_admin @require_admin
async def update_features(slug: str): async def update_features(slug: str):
@@ -238,14 +313,7 @@ def register():
}) })
features = result.get("features", {}) features = result.get("features", {})
html = _render_features(features, post, result)
from sx.sx_components import render_features_panel
html = render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
return sx_response(html) return sx_response(html)
@bp.put("/admin/sumup/") @bp.put("/admin/sumup/")
@@ -278,13 +346,7 @@ def register():
result = await call_action("blog", "update-page-config", payload=payload) result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {}) features = result.get("features", {})
from sx.sx_components import render_features_panel html = _render_features(features, post, result)
html = render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
return sx_response(html) return sx_response(html)
@bp.get("/entries/calendar/<int:calendar_id>/") @bp.get("/entries/calendar/<int:calendar_id>/")
@@ -353,8 +415,7 @@ def register():
post_id = g.post_data["post"]["id"] post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id) associated_entry_ids = await get_post_entry_ids(post_id)
from sx.sx_components import render_calendar_view html = _render_calendar_view(
html = render_calendar_view(
calendar_obj, year, month, month_name, weekday_names, weeks, calendar_obj, year, month, month_name, weekday_names, weeks,
prev_month, prev_month_year, next_month, next_month_year, prev_month, prev_month_year, next_month, next_month_year,
prev_year, next_year, month_entries, associated_entry_ids, prev_year, next_year, month_entries, associated_entry_ids,
@@ -406,11 +467,9 @@ def register():
).scalars().all() ).scalars().all()
# Return the associated entries admin list + OOB update for nav entries # Return the associated entries admin list + OOB update for nav entries
from sx.sx_components import render_associated_entries, render_nav_entries_oob
post = g.post_data["post"] post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"]) admin_list = _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post) nav_entries_html = _render_nav_entries_oob(associated_entries, calendars, post)
return sx_response(admin_list + nav_entries_html) return sx_response(admin_list + nav_entries_html)
@@ -468,7 +527,7 @@ def register():
except OptimisticLockError: except OptimisticLockError:
from urllib.parse import quote from urllib.parse import quote
return redirect( return redirect(
host_url(url_for("blog.post.admin.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.") + "?error=" + quote("Someone else edited this post. Please reload and try again.")
) )
@@ -479,7 +538,7 @@ def register():
await invalidate_tag_cache("post.post_detail") await invalidate_tag_cache("post.post_detail")
# Redirect using the (possibly new) slug # Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.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/") @bp.post("/edit/")
@require_post_author @require_post_author
@@ -504,11 +563,11 @@ def register():
try: try:
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return redirect(host_url(url_for("blog.post.admin.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) ok, reason = validate_lexical(lexical_doc)
if not ok: 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 # Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin")) is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -544,7 +603,7 @@ def register():
) )
except OptimisticLockError: except OptimisticLockError:
return redirect( 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.") + "?error=" + quote("Someone else edited this post. Please reload and try again.")
) )
@@ -560,7 +619,7 @@ def register():
await invalidate_tag_cache("post.post_detail") await invalidate_tag_cache("post.post_detail")
# Redirect to GET (PRG pattern) — use post.slug in case it changed # Redirect to GET (PRG pattern) — use post.slug in case it changed
redirect_url = host_url(url_for("blog.post.admin.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: if publish_requested_msg:
redirect_url += "&publish_requested=1" redirect_url += "&publish_requested=1"
return redirect(redirect_url) return redirect(redirect_url)
@@ -585,8 +644,11 @@ def register():
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel slug = post.get("slug", "")
return sx_response(render_markets_panel(page_markets, post)) create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = sx_call("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
@bp.post("/markets/new/") @bp.post("/markets/new/")
@require_admin @require_admin
@@ -611,8 +673,11 @@ def register():
# Return updated markets list # Return updated markets list
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel slug = post.get("slug", "")
return sx_response(render_markets_panel(page_markets, post)) create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = sx_call("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
@bp.delete("/markets/<market_slug>/") @bp.delete("/markets/<market_slug>/")
@require_admin @require_admin
@@ -631,7 +696,10 @@ def register():
# Return updated markets list # Return updated markets list
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel slug = post.get("slug", "")
return sx_response(render_markets_panel(page_markets, post)) create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = sx_call("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
return bp return bp

View File

@@ -105,27 +105,64 @@ def register():
@cache_page(tag="post.post_detail") @cache_page(tag="post.post_detail")
async def post_detail(slug: str): async def post_detail(slug: str):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_post_page, render_post_oob from shared.sx.helpers import (
sx_call, root_header_sx, full_page_sx, oob_page_sx,
post_header_sx, oob_header_sx, mobile_menu_sx,
post_mobile_nav_sx, mobile_root_nav_sx,
)
from shared.services.registry import services
from shared.browser.app.csrf import generate_csrf_token
from shared.utils import host_url
tctx = await get_template_context() tctx = await get_template_context()
# Render post content via .sx defcomp
post = tctx.get("post") or {}
user = getattr(g, "user", None)
rights = tctx.get("rights") or {}
blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/")
csrf = generate_csrf_token()
svc = services.blog_page
detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base)
content = sx_call("blog-post-detail-content", **detail_data)
meta_data = svc.post_meta_data(post, tctx.get("base_title", ""))
meta = sx_call("blog-meta", **meta_data)
if not is_htmx_request(): if not is_htmx_request():
html = await render_post_page(tctx) root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
return await make_response(html) return await make_response(html)
else: else:
sx_src = await render_post_oob(tctx) root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
sx_src = await oob_page_sx(oobs=header_oob, content=content, menu=
mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)))
return sx_response(sx_src) return sx_response(sx_src)
@bp.post("/like/toggle/") @bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user") @clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str): async def like_toggle(slug: str):
from shared.utils import host_url from shared.utils import host_url
from sx.sx_components import render_like_toggle_button from shared.sx.helpers import sx_call
from shared.browser.app.csrf import generate_csrf_token
like_url = host_url(url_for('blog.post.like_toggle', slug=slug)) like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
csrf = generate_csrf_token()
def _like_btn(liked):
return sx_call("blog-like-toggle",
like_url=like_url,
hx_headers={"X-CSRFToken": csrf},
heart="\u2764\ufe0f" if liked else "\U0001f90d")
# Get post_id from g.post_data
if not g.user: if not g.user:
return sx_response(render_like_toggle_button(slug, False, like_url), status=403) return sx_response(_like_btn(False), status=403)
post_id = g.post_data["post"]["id"] post_id = g.post_data["post"]["id"]
user_id = g.user.id user_id = g.user.id
@@ -133,9 +170,8 @@ def register():
result = await call_action("likes", "toggle", payload={ result = await call_action("likes", "toggle", payload={
"user_id": user_id, "target_type": "post", "target_id": post_id, "user_id": user_id, "target_type": "post", "target_id": post_id,
}) })
liked = result["liked"]
return sx_response(render_like_toggle_button(slug, liked, like_url)) return sx_response(_like_btn(result["liked"]))
@bp.get("/w/<widget_domain>/") @bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str): async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -1,53 +1,25 @@
from __future__ import annotations 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.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.helpers import sx_response, sx_call
from shared.sx.helpers import sx_response
from models import Snippet from models import Snippet
VALID_VISIBILITY = frozenset({"private", "shared", "admin"}) VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
async def _visible_snippets(session): async def _render_snippets():
"""Return snippets visible to the current user (own + shared + admin-if-admin).""" """Render snippets list via service data + .sx defcomp."""
uid = g.user.id from shared.services.registry import services
is_admin = g.rights.get("admin") data = await services.blog_page.snippets_data(g.s)
return sx_call("blog-snippets-content", **data)
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
return rows
def register(): def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets") 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>/") @bp.delete("/<int:snippet_id>/")
@require_login @require_login
async def delete_snippet(snippet_id: int): async def delete_snippet(snippet_id: int):
@@ -63,9 +35,7 @@ def register():
await g.s.delete(snippet) await g.s.delete(snippet)
await g.s.flush() await g.s.flush()
snippets = await _visible_snippets(g.s) return sx_response(await _render_snippets())
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, is_admin))
@bp.patch("/<int:snippet_id>/visibility/") @bp.patch("/<int:snippet_id>/visibility/")
@require_login @require_login
@@ -87,8 +57,6 @@ def register():
snippet.visibility = visibility snippet.visibility = visibility
await g.s.flush() await g.s.flush()
snippets = await _visible_snippets(g.s) return sx_response(await _render_snippets())
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, True))
return bp return bp

38
blog/queries.sx Normal file
View File

@@ -0,0 +1,38 @@
;; Blog service — inter-service data queries
(defquery post-by-slug (&key slug)
"Fetch a single blog post by its URL slug."
(service "blog" "get-post-by-slug" :slug slug))
(defquery post-by-id (&key id)
"Fetch a single blog post by its primary key."
(service "blog" "get-post-by-id" :id id))
(defquery posts-by-ids (&key ids)
"Fetch multiple blog posts by comma-separated IDs."
(service "blog" "get-posts-by-ids" :ids (split-ids ids)))
(defquery search-posts (&key query page per-page)
"Search blog posts by text query, paginated."
(let ((result (service "blog" "search-posts"
:query query :page page :per-page per-page)))
{"posts" (nth result 0) "total" (nth result 1)}))
(defquery page-config-ensure (&key container-type container-id)
"Get or create a PageConfig for a container."
(service "page-config" "ensure"
:container-type container-type :container-id container-id))
(defquery page-config (&key container-type container-id)
"Return a single PageConfig by container type + id."
(service "page-config" "get-by-container"
:container-type container-type :container-id container-id))
(defquery page-config-by-id (&key id)
"Return a single PageConfig by primary key."
(service "page-config" "get-by-id" :id id))
(defquery page-configs-batch (&key container-type ids)
"Return PageConfigs for multiple container IDs (comma-separated)."
(service "page-config" "get-batch"
:container-type container-type :ids (split-ids ids)))

View File

@@ -71,8 +71,16 @@ def register_domain_services() -> None:
Blog owns: Post, Tag, Author, PostAuthor, PostTag. Blog owns: Post, Tag, Author, PostAuthor, PostTag.
Cross-app calls go over HTTP via call_action() / fetch_data(). Cross-app calls go over HTTP via call_action() / fetch_data().
""" """
# Federation needed for AP shared infrastructure (activitypub blueprint)
from shared.services.registry import services from shared.services.registry import services
services.register("blog", blog_service)
from shared.services.page_config_impl import SqlPageConfigService
services.register("page_config", SqlPageConfigService())
# Federation needed for AP shared infrastructure (activitypub blueprint)
if not services.has("federation"): if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService() services.federation = SqlFederationService()
from .blog_page import BlogPageService
services.register("blog_page", BlogPageService())

472
blog/services/blog_page.py Normal file
View File

@@ -0,0 +1,472 @@
"""Blog page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
from shared.sx.parser import SxExpr
def _sx_content_expr(raw: str) -> SxExpr | None:
"""Wrap non-empty sx_content as SxExpr so it serializes unquoted."""
return SxExpr(raw) if raw else None
class BlogPageService:
"""Service for blog page data, callable via (service "blog-page" ...)."""
async def cache_data(self, session, **kw):
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
return {
"clear_url": qurl("settings.cache_clear"),
"csrf": generate_csrf_token(),
}
async def snippets_data(self, session, **kw):
from quart import g, url_for as qurl
from sqlalchemy import select, or_
from models import Snippet
from shared.browser.app.csrf import generate_csrf_token
uid = g.user.id
is_admin = g.rights.get("admin")
csrf = generate_csrf_token()
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
snippets = []
for s in rows:
s_id = s.id
s_vis = s.visibility or "private"
s_uid = s.user_id
owner = "You" if s_uid == uid else f"User #{s_uid}"
can_delete = s_uid == uid or is_admin
d = {
"id": s_id,
"name": s.name or "",
"visibility": s_vis,
"owner": owner,
"can_delete": can_delete,
}
if is_admin:
d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
if can_delete:
d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
snippets.append(d)
return {
"snippets": snippets,
"is_admin": bool(is_admin),
"csrf": csrf,
}
async def menu_items_data(self, session, **kw):
from quart import url_for as qurl
from bp.menu_items.services.menu_items import get_all_menu_items
from shared.browser.app.csrf import generate_csrf_token
menu_items = await get_all_menu_items(session)
csrf = generate_csrf_token()
items = []
for mi in menu_items:
i_id = mi.id
label = mi.label or ""
fi = getattr(mi, "feature_image", None)
sort = mi.position or 0
items.append({
"id": i_id,
"label": label,
"url": mi.url or "",
"sort_order": sort,
"feature_image": fi,
"edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
"delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
})
return {
"menu_items": items,
"new_url": qurl("menu_items.new_menu_item"),
"csrf": csrf,
}
async def tag_groups_data(self, session, **kw):
from quart import url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup
from bp.blog.admin.routes import _unassigned_tags
from shared.browser.app.csrf import generate_csrf_token
groups_rows = list(
(await session.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(session)
groups = []
for g in groups_rows:
groups.append({
"id": g.id,
"name": g.name or "",
"slug": getattr(g, "slug", "") or "",
"feature_image": getattr(g, "feature_image", None),
"colour": getattr(g, "colour", None),
"sort_order": getattr(g, "sort_order", 0) or 0,
"edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
})
unassigned_tags = []
for t in unassigned:
unassigned_tags.append({
"name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
})
return {
"groups": groups,
"unassigned_tags": unassigned_tags,
"create_url": qurl("blog.tag_groups_admin.create"),
"csrf": generate_csrf_token(),
}
async def tag_group_edit_data(self, session, *, id=None, **kw):
from quart import abort, url_for as qurl
from sqlalchemy import select
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
from shared.browser.app.csrf import generate_csrf_token
tg = await session.get(TagGroup, id)
if not tg:
abort(404)
assigned_rows = list(
(await session.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
)).scalars()
)
assigned_set = set(assigned_rows)
all_tags_rows = list(
(await session.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
all_tags = []
for t in all_tags_rows:
all_tags.append({
"id": t.id,
"name": getattr(t, "name", "") or "",
"feature_image": getattr(t, "feature_image", None),
"checked": t.id in assigned_set,
})
return {
"group": {
"id": tg.id,
"name": tg.name or "",
"colour": getattr(tg, "colour", "") or "",
"sort_order": getattr(tg, "sort_order", 0) or 0,
"feature_image": getattr(tg, "feature_image", "") or "",
},
"all_tags": all_tags,
"save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
"delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
"csrf": generate_csrf_token(),
}
async def index_data(self, session, **kw):
"""Blog index page data — posts or pages listing with filters."""
from quart import g, request, url_for as qurl
from bp.blog.services.posts_data import posts_data
from bp.blog.services.pages_data import pages_data
from bp.blog.filters.qs import decode
from shared.utils import host_url
from shared.browser.app.csrf import generate_csrf_token
q = decode()
content_type = request.args.get("type", "posts")
is_admin = bool((g.get("rights") or {}).get("admin"))
user = getattr(g, "user", None)
csrf = generate_csrf_token()
blog_url_base = host_url(qurl("blog.index")).rstrip("/index").rstrip("/")
if content_type == "pages":
data = await pages_data(session, q.page, q.search)
posts_list = data.get("pages", [])
tag_groups_raw = []
authors_raw = []
draft_count = 0
selected_tags = ()
selected_authors = ()
selected_groups = ()
else:
show_drafts = bool(q.drafts and user)
drafts_user_id = None if (not show_drafts or is_admin) else user.id
count_drafts_uid = None if (user and is_admin) else (user.id if user else False)
data = await posts_data(
session, q.page, q.search, q.sort, q.selected_tags,
q.selected_authors, q.liked,
drafts=show_drafts, drafts_user_id=drafts_user_id,
count_drafts_for_user_id=count_drafts_uid,
selected_groups=q.selected_groups,
)
posts_list = data.get("posts", [])
tag_groups_raw = data.get("tag_groups", [])
authors_raw = data.get("authors", [])
draft_count = data.get("draft_count", 0)
selected_tags = q.selected_tags
selected_authors = q.selected_authors
selected_groups = q.selected_groups
page_num = data.get("page", q.page)
total_pages = data.get("total_pages", 1)
card_widgets = data.get("card_widgets_html", {})
current_local_href = f"{blog_url_base}/index"
if content_type == "pages":
current_local_href += "?type=pages"
hx_select = "#main-panel"
# Serialize posts for cards
def _format_ts(dt):
if not dt:
return ""
return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt)
cards = []
for p in posts_list:
slug = p.get("slug", "")
href = f"{blog_url_base}/{slug}/"
status = p.get("status", "published")
is_draft = status == "draft"
ts = _format_ts(p.get("updated_at") if is_draft else p.get("published_at"))
tags = []
for t in (p.get("tags") or []):
name = t.get("name") or getattr(t, "name", "")
fi = t.get("feature_image") or getattr(t, "feature_image", None)
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
authors = []
for a in (p.get("authors") or []):
name = a.get("name") or getattr(a, "name", "")
img = a.get("profile_image") or getattr(a, "profile_image", None)
authors.append({"name": name, "image": img or ""})
card = {
"slug": slug, "href": href, "hx_select": hx_select,
"title": p.get("title", ""), "feature_image": p.get("feature_image"),
"excerpt": p.get("custom_excerpt") or p.get("excerpt", ""),
"is_draft": is_draft,
"publish_requested": p.get("publish_requested", False) if is_draft else False,
"status_timestamp": ts,
"tags": tags, "authors": authors,
"has_like": bool(user),
}
if user:
card["liked"] = p.get("is_liked", False)
card["like_url"] = f"{blog_url_base}/{slug}/like/toggle/"
card["csrf_token"] = csrf
widget = card_widgets.get(str(p.get("id", "")), "")
if widget:
card["widget"] = widget
# Page-specific fields
features = p.get("features") or {}
if content_type == "pages":
card["has_calendar"] = features.get("calendar", False)
card["has_market"] = features.get("market", False)
card["pub_timestamp"] = ts
cards.append(card)
# Serialize tag groups for filter
tag_groups = []
for grp in tag_groups_raw:
g_slug = grp.get("slug", "") if isinstance(grp, dict) else getattr(grp, "slug", "")
g_name = grp.get("name", "") if isinstance(grp, dict) else getattr(grp, "name", "")
g_fi = grp.get("feature_image") if isinstance(grp, dict) else getattr(grp, "feature_image", None)
g_colour = grp.get("colour") if isinstance(grp, dict) else getattr(grp, "colour", None)
g_count = grp.get("post_count", 0) if isinstance(grp, dict) else getattr(grp, "post_count", 0)
if g_count <= 0 and g_slug not in selected_groups:
continue
tag_groups.append({
"slug": g_slug, "name": g_name, "feature_image": g_fi,
"colour": g_colour, "post_count": g_count,
"is_selected": g_slug in selected_groups,
})
# Serialize authors for filter
authors_list = []
for a in authors_raw:
a_slug = a.get("slug", "") if isinstance(a, dict) else getattr(a, "slug", "")
a_name = a.get("name", "") if isinstance(a, dict) else getattr(a, "name", "")
a_img = a.get("profile_image") if isinstance(a, dict) else getattr(a, "profile_image", None)
a_count = a.get("published_post_count", 0) if isinstance(a, dict) else getattr(a, "published_post_count", 0)
authors_list.append({
"slug": a_slug, "name": a_name, "profile_image": a_img,
"published_post_count": a_count,
"is_selected": a_slug in selected_authors,
})
# Filter summary names
tg_summary_names = [grp["name"] for grp in tag_groups if grp["is_selected"]]
au_summary_names = [a["name"] for a in authors_list if a["is_selected"]]
return {
"content_type": content_type,
"view": q.view,
"cards": cards,
"page": page_num,
"total_pages": total_pages,
"current_local_href": current_local_href,
"hx_select": hx_select,
"is_admin": is_admin,
"has_user": bool(user),
"draft_count": draft_count,
"drafts": bool(q.drafts) if user else False,
"new_post_href": f"{blog_url_base}/new/",
"new_page_href": f"{blog_url_base}/new-page/",
"tag_groups": tag_groups,
"authors": authors_list,
"is_any_group": len(selected_groups) == 0 and len(selected_tags) == 0,
"is_any_author": len(selected_authors) == 0,
"tg_summary": ", ".join(tg_summary_names) if tg_summary_names else "",
"au_summary": ", ".join(au_summary_names) if au_summary_names else "",
"blog_url_base": blog_url_base,
"csrf": csrf,
}
async def post_admin_data(self, session, *, slug=None, **kw):
"""Post admin panel — just needs post loaded into context."""
from quart import g
from sqlalchemy import select
from shared.models.page_config import PageConfig
# _ensure_post_data is called by before_request in defpage context
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
if post.get("is_page"):
pc = (await session.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)
return {
"features": features,
"sumup_configured": sumup_configured,
}
def post_meta_data(self, post, base_title):
"""Compute SEO meta tag values from post dict."""
import re
from quart import request as req
is_public = post.get("visibility") == "public"
is_published = post.get("status") == "published"
email_only = post.get("email_only", False)
robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"
desc = (post.get("meta_description") or post.get("og_description") or
post.get("twitter_description") or post.get("custom_excerpt") or
post.get("excerpt") or "")
if not desc and post.get("html"):
desc = re.sub(r'<[^>]+>', '', post["html"])
desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
canonical = post.get("canonical_url") or (req.url if req else "")
post_title = post.get("meta_title") or post.get("title") or ""
page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title
og_title = post.get("og_title") or page_title
tw_title = post.get("twitter_title") or page_title
is_article = not post.get("is_page")
return {
"robots": robots, "page_title": page_title, "desc": desc,
"canonical": canonical,
"og_type": "article" if is_article else "website",
"og_title": og_title, "image": image,
"twitter_card": "summary_large_image" if image else "summary",
"twitter_title": tw_title,
}
def post_detail_data(self, post, user, rights, csrf, blog_url_base):
"""Serialize post detail view data for ~blog-post-detail-content defcomp."""
slug = post.get("slug", "")
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
user_id = getattr(user, "id", None) if user else None
# Tags and authors
tags = []
for t in (post.get("tags") or []):
name = t.get("name") or getattr(t, "name", "")
fi = t.get("feature_image") or getattr(t, "feature_image", None)
tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""})
authors = []
for a in (post.get("authors") or []):
name = a.get("name") or getattr(a, "name", "")
img = a.get("profile_image") or getattr(a, "profile_image", None)
authors.append({"name": name, "image": img or ""})
return {
"slug": slug,
"is_draft": post.get("status") == "draft",
"publish_requested": post.get("publish_requested", False),
"can_edit": is_admin or (user_id is not None and post.get("user_id") == user_id),
"edit_href": f"{blog_url_base}/{slug}/admin/edit/",
"is_page": bool(post.get("is_page")),
"has_user": bool(user),
"liked": post.get("is_liked", False),
"like_url": f"{blog_url_base}/{slug}/like/toggle/",
"csrf": csrf,
"custom_excerpt": post.get("custom_excerpt") or "",
"tags": tags,
"authors": authors,
"feature_image": post.get("feature_image"),
"html_content": post.get("html", ""),
"sx_content": _sx_content_expr(post.get("sx_content", "")),
}
async def preview_data(self, session, *, slug=None, **kw):
"""Build preview data with prettified/rendered content."""
from quart import g
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await session.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
result = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
result["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
result["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)
result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
result["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
result["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
result["lex_rendered"] = "<em>Error rendering lexical</em>"
return result

View File

@@ -169,3 +169,246 @@
(details :class "border rounded bg-white" (details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title) (summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content))) (div :class "p-4 overflow-x-auto text-xs" content)))
(defcomp ~blog-preview-rendered (&key html)
(div :class "blog-content prose max-w-none" (raw! html)))
(defcomp ~blog-preview-empty ()
(div :class "p-8 text-stone-500" "No content to preview."))
(defcomp ~blog-admin-placeholder ()
(div :class "pb-8"))
;; ---------------------------------------------------------------------------
;; Data-driven content defcomps (called from defpages with service data)
;; ---------------------------------------------------------------------------
;; Snippets — receives serialized snippet dicts from service
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
(~blog-snippets-panel
:list (if (empty? (or snippets (list)))
(~empty-state :icon "fa fa-puzzle-piece"
:message "No snippets yet. Create one from the blog editor.")
(~blog-snippets-list
:rows (map (lambda (s)
(let* ((badge-colours (dict
"private" "bg-stone-200 text-stone-700"
"shared" "bg-blue-100 text-blue-700"
"admin" "bg-amber-100 text-amber-700"))
(vis (or (get s "visibility") "private"))
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
(name (get s "name"))
(owner (get s "owner"))
(can-delete (get s "can_delete")))
(~blog-snippet-row
:name name :owner owner :badge-cls badge-cls :visibility vis
:extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers {:X-CSRFToken csrf}
:options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(when can-delete
(~delete-btn
:url (get s "delete_url")
:trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" name "\u201d?")
:sx-headers {:X-CSRFToken csrf}
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
(or snippets (list)))))))
;; Menu Items — receives serialized menu item dicts from service
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
(~blog-menu-items-panel
:new-url new-url
:list (if (empty? (or menu-items (list)))
(~empty-state :icon "fa fa-inbox"
:message "No menu items yet. Add one to get started!")
(~blog-menu-items-list
:rows (map (lambda (mi)
(~blog-menu-item-row
:img (~img-or-placeholder
:src (get mi "feature_image") :alt (get mi "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
:label (get mi "label")
:slug (get mi "url")
:sort-order (str (or (get mi "sort_order") 0))
:edit-url (get mi "edit_url")
:delete-url (get mi "delete_url")
:confirm-text (str "Remove " (get mi "label") " from the menu?")
:hx-headers {:X-CSRFToken csrf}))
(or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~blog-tag-groups-list
:items (map (lambda (g)
(let* ((fi (get g "feature_image"))
(colour (get g "colour"))
(name (get g "name"))
(initial (slice (or name "?") 0 1))
(icon (if fi
(~blog-tag-group-icon-image :src fi :name name)
(~blog-tag-group-icon-color
:style (if colour (str "background:" colour) "background:#e7e5e4")
:initial initial))))
(~blog-tag-group-li
:icon icon
:edit-href (get g "edit_href")
:name name
:slug (or (get g "slug") "")
:sort-order (or (get g "sort_order") 0))))
(or groups (list)))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
:spans (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
(or unassigned-tags (list)))))))
;; Tag Group Edit — receives serialized tag group + tags from service
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~blog-tag-group-edit-main
:edit-form (~blog-tag-group-edit-form
:save-url save-url :csrf csrf
:name (get group "name")
:colour (get group "colour")
:sort-order (get group "sort_order")
:feature-image (get group "feature_image")
:tags (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "id")
:checked (get t "checked")
:img (when (get t "feature_image")
(~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
;; ---------------------------------------------------------------------------
;; Preview content composition — replaces _h_post_preview_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
(let* ((sections (list)))
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
(~blog-preview-empty)
(~blog-preview-panel :sections
(<>
(when sx-pretty
(~blog-preview-section :title "S-Expression Source" :content sx-pretty))
(when json-pretty
(~blog-preview-section :title "Lexical JSON" :content json-pretty))
(when sx-rendered
(~blog-preview-section :title "SX Rendered"
:content (~blog-preview-rendered :html sx-rendered)))
(when lex-rendered
(~blog-preview-section :title "Lexical Rendered"
:content (~blog-preview-rendered :html lex-rendered))))))))
;; ---------------------------------------------------------------------------
;; Data introspection composition — replaces _h_post_data_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-data-value-cell (&key value value-type)
(if (= value-type "nil")
(span :class "text-neutral-400" "\u2014")
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
(if (or (= value-type "date") (= value-type "other"))
(code value)
value))))
(defcomp ~blog-data-scalar-table (&key columns)
(div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
(thead :class "bg-neutral-50/70"
(tr (th :class "px-3 py-2 text-left font-medium w-40 sm:w-56" "Field")
(th :class "px-3 py-2 text-left font-medium" "Value")))
(tbody
(map (lambda (col)
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key"))
(td :class "px-3 py-2 align-top"
(~blog-data-value-cell :value (get col "value") :value-type (get col "type")))))
(or columns (list)))))))
(defcomp ~blog-data-relationship-item (&key index summary children)
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
(td :class "px-2 py-1 align-top"
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
(code summary))
(when children
(div :class "mt-2 pl-3 border-l border-neutral-200"
(~blog-data-model-content
:columns (get children "columns")
:relationships (get children "relationships")))))))
(defcomp ~blog-data-relationship (&key name cardinality class-name loaded value)
(div :class "rounded-xl border border-neutral-200"
(div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
"Relationship: " (span :class "font-semibold" name)
(span :class "ml-2 text-xs text-neutral-500"
cardinality " \u2192 " class-name
(when (not loaded) " \u2022 " (em "not loaded"))))
(div :class "p-3 text-sm"
(if (not value)
(span :class "text-neutral-400" "\u2014")
(if (get value "is_list")
(<>
(div :class "text-neutral-500 mb-2"
(str (get value "count") " item" (if (= (get value "count") 1) "" "s")))
(when (get value "items")
(div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden"
(thead :class "bg-neutral-50/70"
(tr (th :class "px-2 py-1 text-left w-10" "#")
(th :class "px-2 py-1 text-left" "Summary")))
(tbody
(map (lambda (item)
(~blog-data-relationship-item
:index (get item "index")
:summary (get item "summary")
:children (get item "children")))
(get value "items")))))))
;; Single value
(<>
(pre :class "whitespace-pre-wrap break-words break-all text-xs mb-2"
(code (get value "summary")))
(when (get value "children")
(div :class "pl-3 border-l border-neutral-200"
(~blog-data-model-content
:columns (get (get value "children") "columns")
:relationships (get (get value "children") "relationships"))))))))))
(defcomp ~blog-data-model-content (&key columns relationships)
(div :class "space-y-4"
(~blog-data-scalar-table :columns columns)
(when (not (empty? (or relationships (list))))
(div :class "space-y-3"
(map (lambda (rel)
(~blog-data-relationship
:name (get rel "name")
:cardinality (get rel "cardinality")
:class-name (get rel "class_name")
:loaded (get rel "loaded")
:value (get rel "value")))
relationships)))))
(defcomp ~blog-data-table-content (&key tablename model-data)
(if (not model-data)
(div :class "px-4 py-8 text-stone-400" "No post data available.")
(div :class "px-4 py-8"
(div :class "mb-6 text-sm text-neutral-500"
"Model: " (code "Post") " \u2022 Table: " (code tablename))
(~blog-data-model-content
:columns (get model-data "columns")
:relationships (get model-data "relationships")))))

View File

@@ -2,8 +2,7 @@
(defcomp ~blog-like-button (&key like-url hx-headers heart) (defcomp ~blog-like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl" (div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(button :sx-post like-url :sx-swap "outerHTML" (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
:sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp) (defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1" (<> (div :class "flex justify-center gap-2 mt-1"
@@ -56,7 +55,7 @@
(when has-like (when has-like
(~blog-like-button (~blog-like-button
:like-url like-url :like-url like-url
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}") :hx-headers {:X-CSRFToken csrf-token}
:heart (if liked "❤️" "🤍"))) :heart (if liked "❤️" "🤍")))
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true" :sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"

View File

@@ -12,10 +12,13 @@
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested")) (when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
edit)) edit))
(defcomp ~blog-like-toggle (&key like-url hx-headers heart)
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart))
(defcomp ~blog-detail-like (&key like-url hx-headers heart) (defcomp ~blog-detail-like (&key like-url hx-headers heart)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl" (div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(button :sx-post like-url :sx-swap "outerHTML" (~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
:sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-detail-excerpt (&key excerpt) (defcomp ~blog-detail-excerpt (&key excerpt)
(div :class "w-full text-center italic text-3xl p-2" excerpt)) (div :class "w-full text-center italic text-3xl p-2" excerpt))
@@ -36,6 +39,37 @@
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content))))) (when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
(div :class "pb-8"))) (div :class "pb-8")))
;; ---------------------------------------------------------------------------
;; Data-driven composition — replaces _post_main_panel_sx
;; ---------------------------------------------------------------------------
(defcomp ~blog-post-detail-content (&key slug is-draft publish-requested can-edit edit-href
is-page has-user liked like-url csrf
custom-excerpt tags authors
feature-image html-content sx-content)
(let* ((hx-select "#main-panel")
(draft-sx (when is-draft
(~blog-detail-draft
:publish-requested publish-requested
:edit (when can-edit
(~blog-detail-edit-link :href edit-href :hx-select hx-select)))))
(chrome-sx (when (not is-page)
(~blog-detail-chrome
:like (when has-user
(~blog-detail-like
:like-url like-url
:hx-headers {:X-CSRFToken csrf}
:heart (if liked "❤️" "🤍")))
:excerpt (when (not (= custom-excerpt ""))
(~blog-detail-excerpt :excerpt custom-excerpt))
:at-bar (~blog-at-bar :tags tags :authors authors)))))
(~blog-detail-main
:draft draft-sx
:chrome chrome-sx
:feature-image feature-image
:html-content html-content
:sx-content sx-content)))
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title) (defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
(<> (<>
(meta :name "robots" :content robots) (meta :name "robots" :content robots)

View File

@@ -303,3 +303,48 @@
;; Drag over editor ;; Drag over editor
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }")) ".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
;; ---------------------------------------------------------------------------
;; Editor panel composition — replaces render_editor_panel (new post/page)
;; ---------------------------------------------------------------------------
(defcomp ~blog-editor-content (&key csrf title-placeholder create-label
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-form :csrf csrf :title-placeholder title-placeholder
:create-label create-label)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))
;; ---------------------------------------------------------------------------
;; Edit content composition — replaces _h_post_edit_content (existing post)
;; ---------------------------------------------------------------------------
(defcomp ~blog-edit-content (&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
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-edit-form
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
:sx-content-val sx-content-val :lexical-json lexical-json
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options newsletter-options :footer-extra footer-extra)
(~blog-editor-publish-js :already-emailed already-emailed)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))

View File

@@ -1,4 +1,5 @@
;; Blog link-card fragment handler ;; Blog link-card fragment handler
;; returns: sx
;; ;;
;; Renders link-card(s) for blog posts by slug. ;; Renders link-card(s) for blog posts by slug.
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z). ;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).

View File

@@ -1,4 +1,5 @@
;; Blog nav-tree fragment handler ;; Blog nav-tree fragment handler
;; returns: sx
;; ;;
;; Renders the full scrollable navigation menu bar with app icons. ;; Renders the full scrollable navigation menu bar with app icons.
;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB. ;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB.

View File

@@ -30,3 +30,224 @@
tag-groups-filter tag-groups-filter
authors-filter) authors-filter)
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML"))) (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))
;; ---------------------------------------------------------------------------
;; Data-driven composition defcomps — replace Python sx_components functions
;; ---------------------------------------------------------------------------
;; Helper: CSS class for filter item based on selection state
(defcomp ~blog-filter-cls (&key is-on)
;; Returns nothing — use inline (if is-on ...) instead
nil)
;; Blog index main content — replaces _blog_main_panel_sx
(defcomp ~blog-index-main-content (&key content-type view cards page total-pages
current-local-href hx-select blog-url-base)
(let* ((posts-href (str blog-url-base "/index"))
(pages-href (str posts-href "?type=pages"))
(posts-cls (if (not (= content-type "pages"))
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200"))
(pages-cls (if (= content-type "pages")
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")))
(if (= content-type "pages")
;; Pages listing
(~blog-main-panel-pages
:tabs (~blog-content-type-tabs
:posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:cards (<>
(map (lambda (card)
(~blog-page-card
:href (get card "href") :hx-select hx-select
:title (get card "title")
:has-calendar (get card "has_calendar")
:has-market (get card "has_market")
:pub-timestamp (get card "pub_timestamp")
:feature-image (get card "feature_image")
:excerpt (get card "excerpt")))
(or cards (list)))
(if (< page total-pages)
(~sentinel-simple
:id (str "sentinel-" page "-d")
:next-url (str current-local-href
(if (contains? current-local-href "?") "&" "?")
"page=" (+ page 1)))
(if (not (empty? (or cards (list))))
(~end-of-results)
(~blog-no-pages)))))
;; Posts listing
(let* ((grid-cls (if (= view "tile")
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
"max-w-full px-3 py-3 space-y-3"))
(list-href current-local-href)
(tile-href (str current-local-href
(if (contains? current-local-href "?") "&" "?") "view=tile"))
(list-cls (if (not (= view "tile"))
"bg-stone-200 text-stone-800"
"text-stone-400 hover:text-stone-600"))
(tile-cls (if (= view "tile")
"bg-stone-200 text-stone-800"
"text-stone-400 hover:text-stone-600")))
(~blog-main-panel-posts
:tabs (~blog-content-type-tabs
:posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:toggle (~view-toggle
:list-href list-href :tile-href tile-href :hx-select hx-select
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
:list-svg (~list-svg) :tile-svg (~tile-svg))
:grid-cls grid-cls
:cards (<>
(map (lambda (card)
(if (= view "tile")
(~blog-card-tile
:href (get card "href") :hx-select hx-select
:feature-image (get card "feature_image")
:title (get card "title") :is-draft (get card "is_draft")
:publish-requested (get card "publish_requested")
:status-timestamp (get card "status_timestamp")
:excerpt (get card "excerpt")
:tags (get card "tags") :authors (get card "authors"))
(~blog-card
:slug (get card "slug") :href (get card "href") :hx-select hx-select
:title (get card "title") :feature-image (get card "feature_image")
:excerpt (get card "excerpt") :is-draft (get card "is_draft")
:publish-requested (get card "publish_requested")
:status-timestamp (get card "status_timestamp")
:has-like (get card "has_like") :liked (get card "liked")
:like-url (get card "like_url") :csrf-token (get card "csrf_token")
:tags (get card "tags") :authors (get card "authors")
:widget (get card "widget"))))
(or cards (list)))
(~blog-index-sentinel
:page page :total-pages total-pages
:current-local-href current-local-href)))))))
;; Sentinel for blog index infinite scroll
(defcomp ~blog-index-sentinel (&key page total-pages current-local-href)
(when (< page total-pages)
(let* ((next-url (str current-local-href "?page=" (+ page 1))))
(~sentinel-desktop
:id (str "sentinel-" page "-d")
:next-url next-url
:hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"))))
;; Blog index action buttons — replaces _action_buttons_sx
(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href)
(~blog-action-buttons-wrapper
:inner (<>
(when is-admin
(<>
(~blog-action-button
:href new-post-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
(~blog-action-button
:href new-page-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
(when (and has-user (or draft-count drafts))
(if drafts
(~blog-drafts-button
:href current-local-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
(let* ((on-href (str current-local-href
(if (contains? current-local-href "?") "&" "?") "drafts=1")))
(~blog-drafts-button-amber
:href on-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
;; Tag groups filter — replaces _tag_groups_filter_sx
(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select)
(~blog-filter-nav
:items (<>
(~blog-filter-any-topic
:cls (if is-any-group
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
:hx-select hx-select)
(map (lambda (grp)
(let* ((is-on (get grp "is_selected"))
(cls (if is-on
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(fi (get grp "feature_image"))
(colour (get grp "colour"))
(name (get grp "name"))
(icon (if fi
(~blog-filter-group-icon-image :src fi :name name)
(~blog-filter-group-icon-color
:style (if colour
(str "background-color: " colour "; color: white;")
"background-color: #e7e5e4; color: #57534e;")
:initial (slice (or name "?") 0 1)))))
(~blog-filter-group-li
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
:hx-select hx-select :icon icon
:name name :count (str (get grp "post_count")))))
(or tag-groups (list))))))
;; Authors filter — replaces _authors_filter_sx
(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select)
(~blog-filter-nav
:items (<>
(~blog-filter-any-author
:cls (if is-any-author
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
:hx-select hx-select)
(map (lambda (a)
(let* ((is-on (get a "is_selected"))
(cls (if is-on
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(img (get a "profile_image")))
(~blog-filter-author-li
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
:hx-select hx-select
:icon (when img (~blog-filter-author-icon :src img :name (get a "name")))
:name (get a "name")
:count (str (get a "published_post_count")))))
(or authors (list))))))
;; Blog index aside — replaces _blog_aside_sx
(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author)
(~blog-aside
:search (~search-desktop)
:action-buttons (~blog-index-actions
:is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href)
:tag-groups-filter (~blog-index-tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
:authors-filter (~blog-index-authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select)))
;; Blog index mobile filter — replaces _blog_filter_sx
(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author
tg-summary au-summary)
(~mobile-filter
:filter-summary (<>
(~search-mobile)
(when (not (= tg-summary ""))
(~blog-filter-summary :text tg-summary))
(when (not (= au-summary ""))
(~blog-filter-summary :text au-summary)))
:action-buttons (~blog-index-actions
:is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href)
:filter-details (<>
(~blog-index-tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
(~blog-index-authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select))))

185
blog/sx/layouts.sx Normal file
View File

@@ -0,0 +1,185 @@
;; Blog layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in __init__.py.
;; --- Blog header (invisible row for blog-header-child swap target) ---
(defcomp ~blog-header (&key oob)
(~menu-row-sx :id "blog-row" :level 1
:link-label-content (div)
:child-id "blog-header-child" :oob oob))
;; --- Auto-fetching settings header macro ---
(defmacro ~blog-settings-header-auto (oob)
(quasiquote
(~menu-row-sx :id "root-settings-row" :level 1
:link-href (url-for "settings.defpage_settings_home")
:link-label-content (~blog-admin-label)
:nav (~blog-settings-nav)
:child-id "root-settings-header-child"
:oob (unquote oob))))
;; --- Auto-fetching sub-settings header macro ---
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
(quasiquote
(~menu-row-sx :id (unquote row-id) :level 2
:link-href (url-for (unquote endpoint))
:link-label-content (~blog-sub-settings-label
:icon (str "fa fa-" (unquote icon))
:label (unquote label))
:child-id (unquote child-id)
:oob (unquote oob))))
;; ---------------------------------------------------------------------------
;; Blog layout (root + blog header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-layout-full ()
(<> (~root-header-auto)
(~blog-header)))
(defcomp ~blog-layout-oob ()
(<> (~blog-header :oob true)
(~clear-oob-div :id "blog-header-child")
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Settings layout (root + settings header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)))
(defcomp ~blog-settings-layout-oob ()
(<> (~blog-settings-header-auto true)
(~clear-oob-div :id "root-settings-header-child")
(~root-header-auto true)))
(defcomp ~blog-settings-layout-mobile ()
(~blog-settings-nav))
;; ---------------------------------------------------------------------------
;; Cache layout (root + settings + cache sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-cache-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache")))
(defcomp ~blog-cache-layout-oob ()
(<> (~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache" true)
(~clear-oob-div :id "cache-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Snippets layout (root + settings + snippets sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-snippets-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
(defcomp ~blog-snippets-layout-oob ()
(<> (~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true)
(~clear-oob-div :id "snippets-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Menu Items layout (root + settings + menu-items sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-menu-items-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items")))
(defcomp ~blog-menu-items-layout-oob ()
(<> (~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items" true)
(~clear-oob-div :id "menu_items-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Tag Groups layout (root + settings + tag-groups sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-groups-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups")))
(defcomp ~blog-tag-groups-layout-oob ()
(<> (~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true)
(~clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Tag Group Edit layout (root + settings + tag-groups sub-header with id)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-group-edit-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child")))
(defcomp ~blog-tag-group-edit-layout-oob ()
(<> (~menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child"
:oob true)
(~clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; --- Settings nav links — uses IO primitives ---
(defcomp ~blog-settings-nav ()
(let* ((sc (select-colours))
(links (list
(dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items")
(dict :endpoint "snippets.defpage_snippets_page" :icon "fa fa-puzzle-piece" :label "Snippets")
(dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups")
(dict :endpoint "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache"))))
(<> (map (lambda (lnk)
(~nav-link
:href (url-for (get lnk "endpoint"))
:icon (get lnk "icon")
:label (get lnk "label")
:select-colours (or sc "")))
links))))
;; --- Editor panel wrapper ---
(defcomp ~blog-editor-panel (&key parts)
(<> parts))

View File

@@ -2,7 +2,7 @@
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger) (defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML" (form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
:sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3" :sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
(label :class "flex items-center gap-3 cursor-pointer" (label :class "flex items-center gap-3 cursor-pointer"
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked (input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500" :class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
@@ -54,6 +54,43 @@
(button :type "submit" (button :type "submit"
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create")))) :class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
;; ---------------------------------------------------------------------------
;; Data-driven composition defcomps — replace Python render_* functions
;; ---------------------------------------------------------------------------
;; Features panel composition — replaces render_features_panel
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked
show-sumup sumup-url merchant-code placeholder
sumup-configured checkout-prefix)
(~blog-features-panel
:form (~blog-features-form
:features-url features-url
:calendar-checked calendar-checked
:market-checked market-checked
:hs-trigger "on change trigger submit on closest <form/>")
:sumup (when show-sumup
(~blog-sumup-form
:sumup-url sumup-url
:merchant-code merchant-code
:placeholder placeholder
:sumup-configured sumup-configured
:checkout-prefix checkout-prefix))))
;; Markets panel composition — replaces render_markets_panel
(defcomp ~blog-markets-panel-content (&key markets create-url)
(~blog-markets-panel
:list (if (empty? (or markets (list)))
(~blog-markets-empty)
(~blog-markets-list
:items (map (lambda (m)
(~blog-market-item
:name (get m "name")
:slug (get m "slug")
:delete-url (get m "delete_url")
:confirm-text (str "Delete market '" (get m "name") "'?")))
(or markets (list)))))
:create-url create-url))
;; Associated entries ;; Associated entries
(defcomp ~blog-entry-image (&key src title) (defcomp ~blog-entry-image (&key src title)
@@ -89,3 +126,167 @@
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white" (div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries") (h3 :class "text-lg font-semibold mb-4" "Associated Entries")
content)) content))
;; ---------------------------------------------------------------------------
;; Associated entries composition — replaces _render_associated_entries
;; ---------------------------------------------------------------------------
(defcomp ~blog-associated-entries-from-data (&key entries csrf)
(~blog-associated-entries-panel
:content (if (empty? (or entries (list)))
(~blog-associated-entries-empty)
(~blog-associated-entries-content
:items (map (lambda (e)
(~blog-associated-entry
:confirm-text (get e "confirm_text")
:toggle-url (get e "toggle_url")
:hx-headers {:X-CSRFToken csrf}
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
:name (get e "name")
:date-str (get e "date_str")))
(or entries (list)))))))
;; ---------------------------------------------------------------------------
;; Entries browser composition — replaces _h_post_entries_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-calendar-browser-item (&key name title image view-url)
(details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser"
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
(if image
(img :src image :alt title :class "w-12 h-12 rounded object-cover flex-shrink-0")
(div :class "w-12 h-12 rounded bg-stone-200 flex-shrink-0"))
(div :class "flex-1"
(div :class "font-semibold flex items-center gap-2"
(i :class "fa fa-calendar text-stone-500") " " name)
(div :class "text-sm text-stone-600" title)))
(div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML"
(div :class "text-sm text-stone-400" "Loading calendar..."))))
(defcomp ~blog-entries-browser-content (&key entries-panel calendars)
(div :id "post-entries-content" :class "space-y-6 p-4"
entries-panel
(div :class "space-y-3"
(h3 :class "text-lg font-semibold" "Browse Calendars")
(if (empty? (or calendars (list)))
(div :class "text-sm text-stone-400" "No calendars found.")
(map (lambda (cal)
(~blog-calendar-browser-item
:name (get cal "name")
:title (get cal "title")
:image (get cal "image")
:view-url (get cal "view_url")))
(or calendars (list)))))))
;; ---------------------------------------------------------------------------
;; Post settings form composition — replaces _h_post_settings_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-field-label (&key text field-for)
(label :for field-for
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
(defcomp ~blog-settings-section (&key title content is-open)
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
title)
(div :class "px-[16px] py-[12px] space-y-[12px]" content)))
(defcomp ~blog-settings-form-content (&key csrf updated-at is-page save-success
slug published-at featured visibility email-only
tags feature-image-alt
meta-title meta-description canonical-url
og-title og-description og-image
twitter-title twitter-description twitter-image
custom-template)
(let* ((input-cls "w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] bg-white text-stone-700 placeholder:text-stone-300 focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300")
(textarea-cls (str input-cls " resize-y"))
(slug-placeholder (if is-page "page-slug" "post-slug"))
(tmpl-placeholder (if is-page "custom-page.hbs" "custom-post.hbs"))
(featured-label (if is-page "Featured page" "Featured post")))
(form :method "post" :class "max-w-[640px] mx-auto pb-[48px] px-[16px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "updated_at" :value (or updated-at ""))
(div :class "space-y-[12px] mt-[16px]"
;; General
(~blog-settings-section :title "General" :is-open true :content
(<>
(div (~blog-settings-field-label :text "Slug" :field-for "settings-slug")
(input :type "text" :name "slug" :id "settings-slug" :value (or slug "")
:placeholder slug-placeholder :class input-cls))
(div (~blog-settings-field-label :text "Published at" :field-for "settings-published_at")
(input :type "datetime-local" :name "published_at" :id "settings-published_at"
:value (or published-at "") :class input-cls))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" featured-label)))
(div (~blog-settings-field-label :text "Visibility" :field-for "settings-visibility")
(select :name "visibility" :id "settings-visibility" :class input-cls
(option :value "public" :selected (= visibility "public") "Public")
(option :value "members" :selected (= visibility "members") "Members")
(option :value "paid" :selected (= visibility "paid") "Paid")))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "email_only" :id "settings-email_only" :checked email-only
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" "Email only")))))
;; Tags
(~blog-settings-section :title "Tags" :content
(div (~blog-settings-field-label :text "Tags (comma-separated)" :field-for "settings-tags")
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
:placeholder "news, updates, featured" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
;; Feature Image
(~blog-settings-section :title "Feature Image" :content
(div (~blog-settings-field-label :text "Alt text" :field-for "settings-feature_image_alt")
(input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt"
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
;; SEO / Meta
(~blog-settings-section :title "SEO / Meta" :content
(<>
(div (~blog-settings-field-label :text "Meta title" :field-for "settings-meta_title")
(input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "")
:placeholder "SEO title" :maxlength "300" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300."))
(div (~blog-settings-field-label :text "Meta description" :field-for "settings-meta_description")
(textarea :name "meta_description" :id "settings-meta_description" :rows "2"
:placeholder "SEO description" :maxlength "500" :class textarea-cls
(or meta-description ""))
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters."))
(div (~blog-settings-field-label :text "Canonical URL" :field-for "settings-canonical_url")
(input :type "url" :name "canonical_url" :id "settings-canonical_url"
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
;; Facebook / OpenGraph
(~blog-settings-section :title "Facebook / OpenGraph" :content
(<>
(div (~blog-settings-field-label :text "OG title" :field-for "settings-og_title")
(input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls))
(div (~blog-settings-field-label :text "OG description" :field-for "settings-og_description")
(textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls
(or og-description "")))
(div (~blog-settings-field-label :text "OG image URL" :field-for "settings-og_image")
(input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "")
:placeholder "https://..." :class input-cls))))
;; X / Twitter
(~blog-settings-section :title "X / Twitter" :content
(<>
(div (~blog-settings-field-label :text "Twitter title" :field-for "settings-twitter_title")
(input :type "text" :name "twitter_title" :id "settings-twitter_title"
:value (or twitter-title "") :class input-cls))
(div (~blog-settings-field-label :text "Twitter description" :field-for "settings-twitter_description")
(textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls
(or twitter-description "")))
(div (~blog-settings-field-label :text "Twitter image URL" :field-for "settings-twitter_image")
(input :type "url" :name "twitter_image" :id "settings-twitter_image"
:value (or twitter-image "") :placeholder "https://..." :class input-cls))))
;; Advanced
(~blog-settings-section :title "Advanced" :content
(div (~blog-settings-field-label :text "Custom template" :field-for "settings-custom_template")
(input :type "text" :name "custom_template" :id "settings-custom_template"
:value (or custom-template "") :placeholder tmpl-placeholder :class input-cls))))
(div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200"
(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 settings")
(when save-success
(span :class "text-[14px] text-green-600" "Saved."))))))

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages.""" """Blog defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations from __future__ import annotations
from typing import Any
def setup_blog_pages() -> None: def setup_blog_pages() -> None:
"""Register blog-specific layouts, page helpers, and load page definitions.""" """Register blog-specific layouts, page helpers, and load page definitions."""
from .layouts import _register_blog_layouts
from .helpers import _register_blog_helpers
_register_blog_layouts() _register_blog_layouts()
_register_blog_helpers() _register_blog_helpers()
_load_blog_page_files() _load_blog_page_files()
@@ -14,265 +14,7 @@ def setup_blog_pages() -> None:
def _load_blog_page_files() -> None: def _load_blog_page_files() -> None:
import os import os
from shared.sx.pages import load_page_dir from shared.sx.pages import load_page_dir
from shared.sx.jinja_bridge import load_service_components
blog_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
load_service_components(blog_dir, service_name="blog")
load_page_dir(os.path.dirname(__file__), "blog") load_page_dir(os.path.dirname(__file__), "blog")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_blog_layouts() -> None:
from shared.sx.layouts import register_custom_layout
# :blog — root + blog header (for new-post, new-page)
register_custom_layout("blog", _blog_full, _blog_oob)
# :blog-settings — root + settings header (with settings nav menu)
register_custom_layout("blog-settings", _settings_full, _settings_oob,
mobile_fn=_settings_mobile)
# Sub-settings layouts (root + settings + sub header)
register_custom_layout("blog-cache", _cache_full, _cache_oob)
register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
register_custom_layout("blog-tag-group-edit",
_tag_group_edit_full, _tag_group_edit_oob)
# --- Blog layout (root + blog header) ---
def _blog_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
return "(<> " + root_hdr + " " + blog_hdr + ")"
def _blog_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
return oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) ---
def _settings_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
return "(<> " + root_hdr + " " + settings_hdr + ")"
def _settings_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
def _settings_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _settings_nav_sx
return _settings_nav_sx(ctx)
# --- Sub-settings helpers ---
def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --- Cache ---
def _cache_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache")
def _cache_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache")
# --- Snippets ---
def _snippets_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
def _snippets_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
# --- Menu Items ---
def _menu_items_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
# --- Tag Groups ---
def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit ---
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# ---------------------------------------------------------------------------
# Page helpers (sync functions available in .sx defpage expressions)
# ---------------------------------------------------------------------------
def _register_blog_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("blog", {
"editor-content": _h_editor_content,
"editor-page-content": _h_editor_page_content,
"post-admin-content": _h_post_admin_content,
"post-data-content": _h_post_data_content,
"post-preview-content": _h_post_preview_content,
"post-entries-content": _h_post_entries_content,
"post-settings-content": _h_post_settings_content,
"post-edit-content": _h_post_edit_content,
"settings-content": _h_settings_content,
"cache-content": _h_cache_content,
"snippets-content": _h_snippets_content,
"menu-items-content": _h_menu_items_content,
"tag-groups-content": _h_tag_groups_content,
"tag-group-edit-content": _h_tag_group_edit_content,
})
def _h_editor_content():
from quart import g
return getattr(g, "editor_content", "")
def _h_editor_page_content():
from quart import g
return getattr(g, "editor_page_content", "")
def _h_post_admin_content():
from quart import g
return getattr(g, "post_admin_content", "")
def _h_post_data_content():
from quart import g
return getattr(g, "post_data_content", "")
def _h_post_preview_content():
from quart import g
return getattr(g, "post_preview_content", "")
def _h_post_entries_content():
from quart import g
return getattr(g, "post_entries_content", "")
def _h_post_settings_content():
from quart import g
return getattr(g, "post_settings_content", "")
def _h_post_edit_content():
from quart import g
return getattr(g, "post_edit_content", "")
def _h_settings_content():
from quart import g
return getattr(g, "settings_content", "")
def _h_cache_content():
from quart import g
return getattr(g, "cache_content", "")
def _h_snippets_content():
from quart import g
return getattr(g, "snippets_content", "")
def _h_menu_items_content():
from quart import g
return getattr(g, "menu_items_content", "")
def _h_tag_groups_content():
from quart import g
return getattr(g, "tag_groups_content", "")
def _h_tag_group_edit_content():
from quart import g
return getattr(g, "tag_group_edit_content", "")

View File

@@ -1,5 +1,6 @@
; Blog app defpage declarations ; Blog app defpage declarations
; Pages kept as Python: home, index, post-detail (cache_page / complex branching) ; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
; All helpers return data dicts — markup composition in SX.
; --- New post/page editors --- ; --- New post/page editors ---
@@ -7,92 +8,147 @@
:path "/new/" :path "/new/"
:auth :admin :auth :admin
:layout :blog :layout :blog
:content (editor-content)) :data (editor-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
(defpage new-page (defpage new-page
:path "/new-page/" :path "/new-page/"
:auth :admin :auth :admin
:layout :blog :layout :blog
:content (editor-page-content)) :data (editor-page-data)
:content (~blog-editor-content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))
; --- Post admin pages (nested under /<slug>/admin/) --- ; --- Post admin pages (absolute paths under /<slug>/admin/) ---
(defpage post-admin (defpage post-admin
:path "/" :path "/<slug>/admin/"
:auth :admin :auth :admin
:layout (:post-admin :selected "admin") :layout (:post-admin :selected "admin")
:content (post-admin-content)) :data (post-admin-data slug)
:content (~blog-admin-placeholder))
(defpage post-data (defpage post-data
:path "/data/" :path "/<slug>/admin/data/"
:auth :admin :auth :admin
:layout (:post-admin :selected "data") :layout (:post-admin :selected "data")
:content (post-data-content)) :data (post-data-data slug)
:content (~blog-data-table-content :tablename tablename :model-data model-data))
(defpage post-preview (defpage post-preview
:path "/preview/" :path "/<slug>/admin/preview/"
:auth :admin :auth :admin
:layout (:post-admin :selected "preview") :layout (:post-admin :selected "preview")
:content (post-preview-content)) :data (post-preview-data slug)
:content (~blog-preview-content
:sx-pretty sx-pretty :json-pretty json-pretty
:sx-rendered sx-rendered :lex-rendered lex-rendered))
(defpage post-entries (defpage post-entries
:path "/entries/" :path "/<slug>/admin/entries/"
:auth :admin :auth :admin
:layout (:post-admin :selected "entries") :layout (:post-admin :selected "entries")
:content (post-entries-content)) :data (post-entries-data slug)
:content (~blog-entries-browser-content
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf)
:calendars calendars))
(defpage post-settings (defpage post-settings
:path "/settings/" :path "/<slug>/admin/settings/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "settings") :layout (:post-admin :selected "settings")
:content (post-settings-content)) :data (post-settings-data slug)
:content (~blog-settings-form-content
:csrf csrf :updated-at updated-at :is-page is-page
:save-success save-success :slug settings-slug
:published-at published-at :featured featured
:visibility visibility :email-only email-only
:tags tags :feature-image-alt feature-image-alt
:meta-title meta-title :meta-description meta-description
:canonical-url canonical-url :og-title og-title
:og-description og-description :og-image og-image
:twitter-title twitter-title :twitter-description twitter-description
:twitter-image twitter-image :custom-template custom-template))
(defpage post-edit (defpage post-edit
:path "/edit/" :path "/<slug>/admin/edit/"
:auth :post_author :auth :post_author
:layout (:post-admin :selected "edit") :layout (:post-admin :selected "edit")
:content (post-edit-content)) :data (post-edit-data slug)
:content (~blog-edit-content
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
:sx-content-val sx-content-val :lexical-json lexical-json
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options (<>
(option :value "" "Select newsletter\u2026")
(map (fn (nl) (option :value (get nl "slug") (get nl "name"))) newsletters))
:footer-extra (when badges
(<> (map (fn (b) (span :class (get b "cls") (get b "text"))) badges)))
:css-href css-href :js-src js-src
:sx-editor-js-src sx-editor-js-src
:init-js init-js :save-error save-error))
; --- Settings pages --- ; --- Settings pages (absolute paths) ---
(defpage settings-home (defpage settings-home
:path "/" :path "/settings/"
:auth :admin :auth :admin
:layout :blog-settings :layout :blog-settings
:content (settings-content)) :content (div :class "max-w-2xl mx-auto px-4 py-6"))
(defpage cache-page (defpage cache-page
:path "/cache/" :path "/settings/cache/"
:auth :admin :auth :admin
:layout :blog-cache :layout :blog-cache
:content (cache-content)) :data (service "blog-page" "cache-data")
:content (~blog-cache-panel :clear-url clear-url :csrf csrf))
; --- Snippets --- ; --- Snippets ---
(defpage snippets-page (defpage snippets-page
:path "/" :path "/settings/snippets/"
:auth :login :auth :login
:layout :blog-snippets :layout :blog-snippets
:content (snippets-content)) :data (service "blog-page" "snippets-data")
:content (~blog-snippets-content
:snippets snippets :is-admin is-admin :csrf csrf))
; --- Menu Items --- ; --- Menu Items ---
(defpage menu-items-page (defpage menu-items-page
:path "/" :path "/settings/menu_items/"
:auth :admin :auth :admin
:layout :blog-menu-items :layout :blog-menu-items
:content (menu-items-content)) :data (service "blog-page" "menu-items-data")
:content (~blog-menu-items-content
:menu-items menu-items :new-url new-url :csrf csrf))
; --- Tag Groups --- ; --- Tag Groups ---
(defpage tag-groups-page (defpage tag-groups-page
:path "/" :path "/settings/tag-groups/"
:auth :admin :auth :admin
:layout :blog-tag-groups :layout :blog-tag-groups
:content (tag-groups-content)) :data (service "blog-page" "tag-groups-data")
:content (~blog-tag-groups-content
:groups groups :unassigned-tags unassigned-tags
:create-url create-url :csrf csrf))
(defpage tag-group-edit (defpage tag-group-edit
:path "/<int:id>/" :path "/settings/tag-groups/<int:id>/"
:auth :admin :auth :admin
:layout :blog-tag-group-edit :layout :blog-tag-group-edit
:content (tag-group-edit-content)) :data (service "blog-page" "tag-group-edit-data" :id id)
:content (~blog-tag-group-edit-content
:group group :all-tags all-tags
:save-url save-url :delete-url delete-url :csrf csrf))

703
blog/sxc/pages/helpers.py Normal file
View File

@@ -0,0 +1,703 @@
"""Blog page helpers — async functions available in .sx defpage expressions.
All helpers return data values (dicts, lists) — no sx_call().
Markup composition lives entirely in .sx defpage and .sx defcomp files.
"""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Shared hydration helpers (kept for auth/g._defpage_ctx side effects)
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def _register_blog_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("blog", {
"editor-data": _h_editor_data,
"editor-page-data": _h_editor_page_data,
"post-admin-data": _h_post_admin_data,
"post-data-data": _h_post_data_data,
"post-preview-data": _h_post_preview_data,
"post-entries-data": _h_post_entries_data,
"post-settings-data": _h_post_settings_data,
"post-edit-data": _h_post_edit_data,
})
# ---------------------------------------------------------------------------
# Editor helpers
# ---------------------------------------------------------------------------
def _editor_init_js(urls: dict, *, form_id: str = "post-edit-form",
has_initial_json: bool = True) -> str:
"""Build the editor initialization JavaScript string.
URLs dict must contain: upload_image, upload_media, upload_file, oembed,
snippets, unsplash_key.
"""
font_size_preamble = (
"(function() {"
" function applyEditorFontSize() {"
" document.documentElement.style.fontSize = '62.5%';"
" document.body.style.fontSize = '1.6rem';"
" }"
" function restoreDefaultFontSize() {"
" document.documentElement.style.fontSize = '';"
" document.body.style.fontSize = '';"
" }"
" applyEditorFontSize();"
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {"
" if (e.detail.target && e.detail.target.id === 'main-panel') {"
" restoreDefaultFontSize();"
" document.body.removeEventListener('htmx:beforeSwap', cleanup);"
" }"
" });"
)
upload_image = urls["upload_image"]
upload_media = urls["upload_media"]
upload_file = urls["upload_file"]
oembed = urls["oembed"]
unsplash_key = urls["unsplash_key"]
snippets = urls["snippets"]
init_body = (
" function init() {"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;"
f" var uploadUrl = '{upload_image}';"
" var uploadUrls = {"
" image: uploadUrl,"
f" media: '{upload_media}',"
f" file: '{upload_file}',"
" };"
" var fileInput = document.getElementById('feature-image-file');"
" var addBtn = document.getElementById('feature-image-add-btn');"
" var deleteBtn = document.getElementById('feature-image-delete-btn');"
" var preview = document.getElementById('feature-image-preview');"
" var emptyState = document.getElementById('feature-image-empty');"
" var filledState = document.getElementById('feature-image-filled');"
" var hiddenUrl = document.getElementById('feature-image-input');"
" var hiddenCaption = document.getElementById('feature-image-caption-input');"
" var captionInput = document.getElementById('feature-image-caption');"
" var uploading = document.getElementById('feature-image-uploading');"
" function showFilled(url) {"
" preview.src = url; hiddenUrl.value = url;"
" emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');"
" }"
" function showEmpty() {"
" preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';"
" emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');"
" }"
" function uploadFile(file) {"
" emptyState.classList.add('hidden'); uploading.classList.remove('hidden');"
" var fd = new FormData(); fd.append('file', file);"
" fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })"
" .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })"
" .then(function(data) {"
" var url = data.images && data.images[0] && data.images[0].url;"
" if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }"
" })"
" .catch(function(e) { showEmpty(); alert(e.message); });"
" }"
" addBtn.addEventListener('click', function() { fileInput.click(); });"
" preview.addEventListener('click', function() { fileInput.click(); });"
" deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });"
" fileInput.addEventListener('change', function() {"
" if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = ''; }"
" });"
" captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');"
" function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }"
" excerpt.addEventListener('input', autoResize); autoResize();"
)
if has_initial_json:
init_body += (
" var dataEl = document.getElementById('lexical-initial-data');"
" var initialJson = dataEl ? dataEl.textContent.trim() : null;"
" if (initialJson) { var hidden = document.getElementById('lexical-json-input'); if (hidden) hidden.value = initialJson; }"
)
initial_json_arg = "initialJson: initialJson,"
else:
initial_json_arg = "initialJson: null,"
init_body += (
" window.mountEditor('lexical-editor', {"
f" {initial_json_arg}"
" csrfToken: csrfToken,"
" uploadUrls: uploadUrls,"
f" oembedUrl: '{oembed}',"
f" unsplashApiKey: '{unsplash_key}',"
f" snippetsUrl: '{snippets}',"
" });"
" if (typeof SxEditor !== 'undefined') {"
" SxEditor.mount('sx-editor', {"
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
" csrfToken: csrfToken,"
" uploadUrls: uploadUrls,"
f" oembedUrl: '{oembed}',"
" onChange: function(sx) {"
" document.getElementById('sx-content-input').value = sx;"
" }"
" });"
" }"
" document.addEventListener('keydown', function(e) {"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
f" e.preventDefault(); document.getElementById('{form_id}').requestSubmit();"
" }"
" });"
" }"
" if (typeof window.mountEditor === 'function') { init(); }"
" else { var _t = setInterval(function() {"
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
" }, 50); }"
"})();"
)
return font_size_preamble + init_body
def _editor_urls() -> dict:
"""Extract editor API URLs and asset paths."""
import os
from quart import url_for as qurl, current_app
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
return {
"upload_image": qurl("blog.editor_api.upload_image"),
"upload_media": qurl("blog.editor_api.upload_media"),
"upload_file": qurl("blog.editor_api.upload_file"),
"oembed": qurl("blog.editor_api.oembed_proxy"),
"snippets": qurl("blog.editor_api.list_snippets"),
"unsplash_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
"css_href": asset_url_fn("scripts/editor.css"),
"js_src": asset_url_fn("scripts/editor.js"),
"sx_editor_js_src": asset_url_fn("scripts/sx-editor.js"),
}
def _h_editor_data(**kw) -> dict:
"""New post editor — return data for ~blog-editor-content."""
from shared.browser.app.csrf import generate_csrf_token
urls = _editor_urls()
csrf = generate_csrf_token()
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return {
"csrf": csrf,
"title-placeholder": "Post title...",
"create-label": "Create Post",
"css-href": urls["css_href"],
"js-src": urls["js_src"],
"sx-editor-js-src": urls["sx_editor_js_src"],
"init-js": init_js,
}
def _h_editor_page_data(**kw) -> dict:
"""New page editor — return data for ~blog-editor-content."""
from shared.browser.app.csrf import generate_csrf_token
urls = _editor_urls()
csrf = generate_csrf_token()
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return {
"csrf": csrf,
"title-placeholder": "Page title...",
"create-label": "Create Page",
"css-href": urls["css_href"],
"js-src": urls["js_src"],
"sx-editor-js-src": urls["sx_editor_js_src"],
"init-js": init_js,
}
# ---------------------------------------------------------------------------
# Post admin helpers
# ---------------------------------------------------------------------------
async def _h_post_admin_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
return {}
# ---------------------------------------------------------------------------
# Data introspection
# ---------------------------------------------------------------------------
def _extract_model_data(obj, depth=0, max_depth=2) -> dict:
"""Recursively extract ORM model data into a nested dict for .sx rendering."""
from markupsafe import escape as esc
# Scalar columns
columns = []
for col in obj.__mapper__.columns:
key = col.key
if key == "_sa_instance_state":
continue
val = getattr(obj, key, None)
if val is None:
columns.append({"key": str(key), "value": "", "type": "nil"})
elif hasattr(val, "isoformat"):
columns.append({"key": str(key), "value": str(esc(val.isoformat())), "type": "date"})
elif isinstance(val, str):
columns.append({"key": str(key), "value": str(esc(val)), "type": "str"})
else:
columns.append({"key": str(key), "value": str(esc(str(val))), "type": "other"})
# Relationships
relationships = []
for rel in obj.__mapper__.relationships:
rel_name = rel.key
loaded = rel_name in obj.__dict__
value = getattr(obj, rel_name, None) if loaded else None
cardinality = "many" if rel.uselist else "one"
cls_name = rel.mapper.class_.__name__
rel_data: dict[str, Any] = {
"name": rel_name,
"cardinality": cardinality,
"class_name": cls_name,
"loaded": loaded,
"value": None,
}
if value is None:
pass # value stays None
elif rel.uselist:
items_list = list(value) if value else []
val_data: dict[str, Any] = {"is_list": True, "count": len(items_list)}
if items_list and depth < max_depth:
items = []
for i, it in enumerate(items_list, 1):
summary = _obj_summary(it)
children = _extract_model_data(it, depth + 1, max_depth) if depth < max_depth else None
items.append({"index": i, "summary": summary, "children": children})
val_data["items"] = items
rel_data["value"] = val_data
else:
child = value
summary = _obj_summary(child)
children = _extract_model_data(child, depth + 1, max_depth) if depth < max_depth else None
rel_data["value"] = {"is_list": False, "summary": summary, "children": children}
relationships.append(rel_data)
return {"columns": columns, "relationships": relationships}
def _obj_summary(obj) -> str:
"""Build a summary string for an ORM object."""
from markupsafe import escape as esc
ident_parts = []
for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
if k in obj.__mapper__.c:
v = getattr(obj, k, "")
ident_parts.append(f"{k}={v}")
return str(esc(" \u2022 ".join(ident_parts) if ident_parts else str(obj)))
async def _h_post_data_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g
original_post = getattr(g, "post_data", {}).get("original_post")
if original_post is None:
return {"tablename": None, "model-data": None}
tablename = getattr(original_post, "__tablename__", "?")
model_data = _extract_model_data(original_post, 0, 2)
return {"tablename": tablename, "model-data": model_data}
# ---------------------------------------------------------------------------
# Preview content
# ---------------------------------------------------------------------------
async def _h_post_preview_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g
from shared.services.registry import services
from shared.sx.helpers import SxExpr
preview = await services.blog_page.preview_data(g.s)
return {
"sx-pretty": SxExpr(preview["sx_pretty"]) if preview.get("sx_pretty") else None,
"json-pretty": SxExpr(preview["json_pretty"]) if preview.get("json_pretty") else None,
"sx-rendered": preview.get("sx_rendered") or None,
"lex-rendered": preview.get("lex_rendered") or None,
}
# ---------------------------------------------------------------------------
# Entries browser
# ---------------------------------------------------------------------------
def _extract_associated_entries_data(all_calendars, associated_entry_ids, post_slug: str) -> list:
"""Extract associated entry data for .sx rendering."""
from quart import url_for as qurl
from shared.utils import host_url
entries = []
for calendar in all_calendars:
cal_entries = getattr(calendar, "entries", []) or []
cal_name = getattr(calendar, "name", "")
cal_post = getattr(calendar, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
for entry in cal_entries:
e_id = getattr(entry, "id", None)
if e_id not in associated_entry_ids:
continue
if getattr(entry, "deleted_at", None) is not None:
continue
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
toggle_url = host_url(qurl("blog.post.admin.toggle_entry",
slug=post_slug, entry_id=e_id))
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
entries.append({
"name": e_name,
"confirm_text": f"This will remove {e_name} from this post",
"toggle_url": toggle_url,
"cal_image": cal_fi or "",
"cal_title": cal_title,
"date_str": f"{cal_name} \u2022 {date_str}",
})
return entries
def _extract_calendar_browser_data(all_calendars, post_slug: str) -> list:
"""Extract calendar browser data for .sx rendering."""
from quart import url_for as qurl
from shared.utils import host_url
calendars = []
for cal in all_calendars:
cal_post = getattr(cal, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
cal_name = getattr(cal, "name", "")
view_url = host_url(qurl("blog.post.admin.calendar_view",
slug=post_slug, calendar_id=cal.id))
calendars.append({
"name": cal_name,
"title": cal_title,
"image": cal_fi or "",
"view_url": view_url,
})
return calendars
async def _h_post_entries_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g
from sqlalchemy import select
from shared.models.calendars import Calendar
from shared.browser.app.csrf import generate_csrf_token
from bp.post.services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
post_slug = g.post_data["post"]["slug"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.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"])
csrf = generate_csrf_token()
entries = _extract_associated_entries_data(
all_calendars, associated_entry_ids, post_slug)
calendars = _extract_calendar_browser_data(all_calendars, post_slug)
return {"entries": entries, "calendars": calendars, "csrf": csrf}
# ---------------------------------------------------------------------------
# Settings form
# ---------------------------------------------------------------------------
async def _h_post_settings_data(slug=None, **kw) -> dict:
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.browser.app.csrf import generate_csrf_token
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.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"
csrf = generate_csrf_token()
p = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
is_page = p.get("is_page", False)
gp = ghost_post
# Extract tag names
tags = gp.get("tags") or []
if tags:
tag_names = ", ".join(
getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t))
for t in tags
)
else:
tag_names = ""
# Published at — trim to datetime-local format
pub_at = gp.get("published_at") or ""
pub_at_val = pub_at[:16] if pub_at else ""
return {
"csrf": csrf,
"updated-at": gp.get("updated_at") or "",
"is-page": is_page,
"save-success": save_success,
"settings-slug": gp.get("slug") or "",
"published-at": pub_at_val,
"featured": bool(gp.get("featured")),
"visibility": gp.get("visibility") or "public",
"email-only": bool(gp.get("email_only")),
"tags": tag_names,
"feature-image-alt": gp.get("feature_image_alt") or "",
"meta-title": gp.get("meta_title") or "",
"meta-description": gp.get("meta_description") or "",
"canonical-url": gp.get("canonical_url") or "",
"og-title": gp.get("og_title") or "",
"og-description": gp.get("og_description") or "",
"og-image": gp.get("og_image") or "",
"twitter-title": gp.get("twitter_title") or "",
"twitter-description": gp.get("twitter_description") or "",
"twitter-image": gp.get("twitter_image") or "",
"custom-template": gp.get("custom_template") or "",
}
# ---------------------------------------------------------------------------
# Post edit content
# ---------------------------------------------------------------------------
def _extract_newsletter_options(newsletters) -> list:
"""Extract newsletter data for .sx rendering."""
return [{"slug": getattr(nl, "slug", ""),
"name": getattr(nl, "name", "")} for nl in newsletters]
def _extract_footer_badges(ghost_post: dict, post: dict, save_success: bool,
publish_requested: bool, already_emailed: bool) -> list:
"""Extract footer badge data for .sx rendering."""
badges = []
if save_success:
badges.append({"cls": "text-[14px] text-green-600", "text": "Saved."})
if publish_requested:
badges.append({"cls": "text-[14px] text-blue-600",
"text": "Publish requested \u2014 an admin will review."})
if post.get("publish_requested"):
badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800",
"text": "Publish requested"})
if already_emailed:
nl_name = ""
newsletter = ghost_post.get("newsletter")
if newsletter:
nl_name = (getattr(newsletter, "name", "")
if not isinstance(newsletter, dict)
else newsletter.get("name", ""))
suffix = f" to {nl_name}" if nl_name else ""
badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800",
"text": f"Emailed{suffix}"})
return badges
async def _h_post_edit_data(slug=None, **kw) -> dict:
await _ensure_post_data(slug)
from quart import g, request as qrequest
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
from shared.browser.app.csrf import generate_csrf_token
from bp.post.admin.routes import _post_to_edit_dict
post_id = g.post_data["post"]["id"]
db_post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(db_post) if db_post else {}
save_success = qrequest.args.get("saved") == "1"
save_error = qrequest.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
csrf = generate_csrf_token()
urls = _editor_urls()
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
is_page = post.get("is_page", False)
feature_image = ghost_post.get("feature_image") or ""
feature_image_caption = ghost_post.get("feature_image_caption") or ""
title_val = ghost_post.get("title") or ""
excerpt_val = ghost_post.get("custom_excerpt") or ""
updated_at = ghost_post.get("updated_at") or ""
status = ghost_post.get("status") or "draft"
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
sx_content = ghost_post.get("sx_content") or ""
has_sx = bool(sx_content)
already_emailed = bool(ghost_post and ghost_post.get("email") and
(ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
email_obj = ghost_post.get("email")
if email_obj and not isinstance(email_obj, dict):
already_emailed = bool(getattr(email_obj, "status", None))
title_placeholder = "Page title..." if is_page else "Post title..."
# Return newsletter data as list of dicts (composed in SX)
nl_options = _extract_newsletter_options(newsletters)
# Return footer badge data as list of dicts (composed in SX)
publish_requested = bool(qrequest.args.get("publish_requested")) if hasattr(qrequest, 'args') else False
badges = _extract_footer_badges(ghost_post, post, save_success,
publish_requested, already_emailed)
init_js = _editor_init_js(urls, form_id="post-edit-form", has_initial_json=True)
return {
"csrf": csrf,
"updated-at": str(updated_at),
"title-val": title_val,
"excerpt-val": excerpt_val,
"feature-image": feature_image,
"feature-image-caption": feature_image_caption,
"sx-content-val": sx_content,
"lexical-json": lexical_json,
"has-sx": has_sx,
"title-placeholder": title_placeholder,
"status": status,
"already-emailed": already_emailed,
"newsletters": nl_options,
"badges": badges,
"css-href": urls["css_href"],
"js-src": urls["js_src"],
"sx-editor-js-src": urls["sx_editor_js_src"],
"init-js": init_js,
"save-error": save_error or None,
}

19
blog/sxc/pages/layouts.py Normal file
View File

@@ -0,0 +1,19 @@
"""Blog layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
def _register_blog_layouts() -> None:
from shared.sx.layouts import register_sx_layout
register_sx_layout("blog", "blog-layout-full", "blog-layout-oob")
register_sx_layout("blog-settings", "blog-settings-layout-full",
"blog-settings-layout-oob", "blog-settings-layout-mobile")
register_sx_layout("blog-cache", "blog-cache-layout-full",
"blog-cache-layout-oob")
register_sx_layout("blog-snippets", "blog-snippets-layout-full",
"blog-snippets-layout-oob")
register_sx_layout("blog-menu-items", "blog-menu-items-layout-full",
"blog-menu-items-layout-oob")
register_sx_layout("blog-tag-groups", "blog-tag-groups-layout-full",
"blog-tag-groups-layout-oob")
register_sx_layout("blog-tag-group-edit", "blog-tag-group-edit-layout-full",
"blog-tag-group-edit-layout-oob")

25
blog/sxc/pages/renders.py Normal file
View File

@@ -0,0 +1,25 @@
"""Blog editor panel rendering."""
from __future__ import annotations
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
"""Build the WYSIWYG editor panel for new post/page creation."""
from shared.browser.app.csrf import generate_csrf_token
from shared.sx.helpers import sx_call
from .helpers import _editor_urls, _editor_init_js
urls = _editor_urls()
csrf = generate_csrf_token()
title_placeholder = "Page title..." if is_page else "Post title..."
create_label = "Create Page" if is_page else "Create Post"
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
return sx_call("blog-editor-content",
csrf=csrf,
title_placeholder=title_placeholder,
create_label=create_label,
css_href=urls["css_href"],
js_src=urls["js_src"],
sx_editor_js_src=urls["sx_editor_js_src"],
init_js=init_js,
save_error=save_error or None)

10
cart/actions.sx Normal file
View File

@@ -0,0 +1,10 @@
;; Cart service — inter-service action endpoints
(defaction adopt-cart-for-user (&key user-id session-id)
"Transfer anonymous cart items to a logged-in user."
(do
(service "cart" "adopt-cart-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))
;; clear-cart-for-order: remains as Python fallback (complex object construction)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file from shared.sx.jinja_bridge import load_service_components # noqa: F401
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
@@ -17,7 +17,6 @@ from bp import (
register_page_cart, register_page_cart,
register_cart_global, register_cart_global,
register_page_admin, register_page_admin,
register_fragments,
register_actions, register_actions,
register_data, register_data,
register_inbox, register_inbox,
@@ -141,7 +140,12 @@ def create_app() -> "Quart":
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/" 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.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments()) import os as _os
load_service_components(_os.path.dirname(_os.path.abspath(__file__)), service_name="cart")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "cart")
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
app.register_blueprint(register_inbox()) app.register_blueprint(register_inbox())
@@ -185,8 +189,6 @@ def create_app() -> "Quart":
from sxc.pages import setup_cart_pages from sxc.pages import setup_cart_pages
setup_cart_pages() setup_cart_pages()
from shared.sx.pages import mount_pages
# --- Blueprint registration --- # --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last # Static prefixes first, dynamic (page_slug) last
@@ -196,21 +198,22 @@ def create_app() -> "Quart":
url_prefix="/", url_prefix="/",
) )
# Cart overview at GET / # Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/") overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
app.register_blueprint(overview_bp, url_prefix="/") 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() 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") 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="/") 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>") 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 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.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global from .cart.global_routes import register as register_cart_global
from .page_admin.routes import register as register_page_admin from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions from .actions import register_actions
from .data import register_data from .data import register_data
from .inbox import register_inbox from .inbox import register_inbox

View File

@@ -1,64 +1,26 @@
"""Cart app action endpoints. """Cart app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for adopt-cart-for-user is defined in ``cart/actions.sx``.
cross-app callers (login handler) via the internal action client. clear-cart-for-order remains as a Python fallback (complex object construction).
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, request
from shared.infrastructure.actions import ACTION_HEADER from shared.infrastructure.query_blueprint import create_action_blueprint
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("cart")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- adopt-cart-for-user ---
async def _adopt_cart():
data = await request.get_json()
await services.cart.adopt_cart_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-cart-for-user"] = _adopt_cart
# --- clear-cart-for-order ---
async def _clear_cart_for_order(): async def _clear_cart_for_order():
"""Soft-delete cart items after an order is paid. Called by orders service."""
from bp.cart.services.clear_cart_for_order import clear_cart_for_order from bp.cart.services.clear_cart_for_order import clear_cart_for_order
from shared.models.order import Order
data = await request.get_json() data = await request.get_json()
user_id = data.get("user_id") user_id = data.get("user_id")
session_id = data.get("session_id") session_id = data.get("session_id")
page_post_id = data.get("page_post_id") page_post_id = data.get("page_post_id")
# Build a minimal order-like object with the fields clear_cart_for_order needs
order = type("_Order", (), { order = type("_Order", (), {
"user_id": user_id, "user_id": user_id,
"session_id": session_id, "session_id": session_id,

View File

@@ -56,9 +56,9 @@ def register(url_prefix: str) -> Blueprint:
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true": if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX # Redirect to overview for HTMX
return redirect(url_for("cart_overview.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>/") @bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int): async def update_quantity(product_id: int):
@@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint:
tickets = await get_ticket_cart_entries(g.s) tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets: if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
product_total = total(cart) or 0 product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0 calendar_amount = calendar_total(calendar_entries) or 0
@@ -145,13 +145,13 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total + calendar_amount + ticket_amount cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("cart_overview.defpage_cart_overview")) return redirect(url_for("defpage_cart_overview"))
try: try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e: except ValueError as e:
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page from sxc.pages.renders import render_checkout_error_page
tctx = await get_template_context() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error=str(e)) html = await render_checkout_error_page(tctx, error=str(e))
return await make_response(html, 400) return await make_response(html, 400)
@@ -208,7 +208,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url: if not hosted_url:
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page from sxc.pages.renders import render_checkout_error_page
tctx = await get_template_context() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.") html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500) return await make_response(html, 500)

View File

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

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix) bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.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/") @bp.post("/checkout/")
async def page_checkout(): async def page_checkout():
post = g.page_post 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) page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets: if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.defpage_page_cart_view")) return redirect(url_for("defpage_page_cart_view"))
product_total_val = total(cart) or 0 product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0 calendar_amount = calendar_total(cal_entries) or 0
@@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total_val + calendar_amount + ticket_amount cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("page_cart.defpage_page_cart_view")) return redirect(url_for("defpage_page_cart_view"))
ident = current_cart_identity() ident = current_cart_identity()
@@ -93,7 +73,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url: if not hosted_url:
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page from sxc.pages.renders import render_checkout_error_page
tctx = await get_template_context() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.") html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500) return await make_response(html, 500)

View File

@@ -1,79 +1,14 @@
"""Cart app data endpoints. """Cart app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``cart/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("cart")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- cart-summary ---
async def _cart_summary():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
page_slug = request.args.get("page_slug")
summary = await services.cart.cart_summary(
g.s, user_id=user_id, session_id=session_id, page_slug=page_slug,
)
return dto_to_dict(summary)
_handlers["cart-summary"] = _cart_summary
# --- cart-items (product slugs + quantities for template rendering) ---
async def _cart_items():
from sqlalchemy import select
from shared.models.market import CartItem
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
filters = [CartItem.deleted_at.is_(None)]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
elif session_id is not None:
filters.append(CartItem.session_id == session_id)
else:
return []
result = await g.s.execute(
select(CartItem).where(*filters)
)
items = result.scalars().all()
return [
{
"product_id": item.product_id,
"product_slug": item.product_slug,
"quantity": item.quantity,
}
for item in items
]
_handlers["cart-items"] = _cart_items
return bp return bp

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

@@ -57,7 +57,7 @@ def register() -> Blueprint:
if not order: if not order:
return await make_response("Order not found", 404) return await make_response("Order not found", 404)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_order_page, render_order_oob from sxc.pages.renders import render_order_page, render_order_oob
ctx = await get_template_context() ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries") calendar_entries = ctx.get("calendar_entries")
@@ -122,7 +122,7 @@ def register() -> Blueprint:
if not hosted_url: if not hosted_url:
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page from sxc.pages.renders import render_checkout_error_page
tctx = await get_template_context() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order) html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
return await make_response(html, 500) return await make_response(html, 500)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response from quart import Blueprint, g, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -138,7 +138,7 @@ def register(url_prefix: str) -> Blueprint:
orders = result.scalars().all() orders = result.scalars().all()
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import ( from sxc.pages.renders import (
render_orders_page, render_orders_page,
render_orders_rows, render_orders_rows,
render_orders_oob, render_orders_oob,
@@ -154,7 +154,7 @@ def register(url_prefix: str) -> Blueprint:
) )
resp = await make_response(html) resp = await make_response(html)
elif page > 1: elif page > 1:
sx_src = await render_orders_rows( sx_src = render_orders_rows(
ctx, orders, page, total_pages, url_for, qs_fn, ctx, orders, page, total_pages, url_for, qs_fn,
) )
resp = sx_response(sx_src) resp = sx_response(sx_src)

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("page_admin", __name__) 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/") @bp.put("/payments/")
@require_admin @require_admin
async def update_sumup(**kwargs): async def update_sumup(**kwargs):
@@ -64,7 +47,7 @@ def register():
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_panel from sxc.pages.renders import render_cart_payments_panel
ctx = await get_template_context() ctx = await get_template_context()
html = render_cart_payments_panel(ctx) html = render_cart_payments_panel(ctx)
return sx_response(html) return sx_response(html)

11
cart/queries.sx Normal file
View File

@@ -0,0 +1,11 @@
;; Cart service — inter-service data queries
(defquery cart-summary (&key user-id session-id page-slug)
"Cart summary for a user or session, optionally filtered by page."
(service "cart" "cart-summary"
:user-id user-id :session-id session-id :page-slug page-slug))
(defquery cart-items (&key user-id session-id)
"Product slugs and quantities in the cart."
(service "cart-data" "cart-items"
:user-id user-id :session-id session-id))

View File

@@ -12,3 +12,9 @@ def register_domain_services() -> None:
from shared.services.cart_impl import SqlCartService from shared.services.cart_impl import SqlCartService
services.cart = SqlCartService() services.cart = SqlCartService()
from shared.services.cart_items_impl import SqlCartItemsService
services.register("cart_data", SqlCartItemsService())
from .cart_page import CartPageService
services.register("cart_page", CartPageService())

226
cart/services/cart_page.py Normal file
View File

@@ -0,0 +1,226 @@
"""Cart page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
from typing import Any
def _serialize_cart_item(item: Any) -> dict:
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:
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:
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 | None:
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,
}
class CartPageService:
"""Service for cart page data, callable via (service "cart-page" ...)."""
async def overview_data(self, session, **kw):
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(session)
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
return {
"page_groups": grp_dicts,
"cart_url_base": cart_url(""),
}
async def page_cart_data(self, session, **kw):
from quart import g, request, url_for
from shared.infrastructure.urls import login_url
from shared.utils import route_prefix
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(session, post.id)
cal_entries = await get_calendar_entries_for_page(session, post.id)
page_tickets = await get_tickets_for_page(session, post.id)
ticket_groups = group_tickets(page_tickets)
# Build summary data
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(page_tickets) if page_tickets else 0
item_count = product_qty + ticket_qty
product_total = total(cart) or 0
cal_total = calendar_total(cal_entries) or 0
tk_total = ticket_total(page_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 = getattr(g, "page_post", None)
summary = {
"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")
summary["checkout_action"] = route_prefix() + action
summary["user_email"] = user.email
else:
summary["login_href"] = login_url(request.url)
return {
"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": summary,
}
async def admin_data(self, session, **kw):
"""Populate post context for cart-admin layout headers."""
from quart import g
from shared.infrastructure.fragments import fetch_fragments
post = g.page_post
slug = post.slug if post else ""
post_id = post.id if post else None
# Fetch container_nav for post header
container_nav = ""
if post_id:
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
container_nav = events_nav + market_nav
return {
"post": {
"id": post_id,
"slug": slug,
"title": (post.title if post else "")[:160],
"feature_image": getattr(post, "feature_image", None),
},
"container_nav": container_nav,
}
async def payments_admin_data(self, session, **kw):
"""Admin data + payments data combined for cart-payments page."""
admin = await self.admin_data(session)
payments = await self.payments_data(session)
return {**admin, **payments}
async def payments_data(self, session, **kw):
from shared.sx.page import get_template_context
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 {"page_config": pc_data}

View File

@@ -1,4 +1,5 @@
;; Cart account-nav-item fragment handler ;; Cart account-nav-item fragment handler
;; returns: sx
;; ;;
;; Renders the "orders" link for the account dashboard nav. ;; Renders the "orders" link for the account dashboard nav.

View File

@@ -1,4 +1,5 @@
;; Cart cart-mini fragment handler ;; Cart cart-mini fragment handler
;; returns: sx
;; ;;
;; Renders the cart icon with badge (or logo when empty). ;; Renders the cart icon with badge (or logo when empty).

View File

@@ -3,6 +3,11 @@
(defcomp ~cart-page-label-img (&key src) (defcomp ~cart-page-label-img (&key src)
(img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) (img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~cart-page-label (&key feature-image title)
(<> (when feature-image
(~cart-page-label-img :src feature-image))
(span title)))
(defcomp ~cart-all-carts-link (&key href) (defcomp ~cart-all-carts-link (&key href)
(a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" (a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")) (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))

View File

@@ -52,3 +52,114 @@
(div :id "cart" (div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" items cal tickets) (div (section :class "space-y-3 sm:space-y-4" items cal tickets)
summary)))) 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)))

136
cart/sx/layouts.sx Normal file
View File

@@ -0,0 +1,136 @@
;; Cart layout defcomps — fully self-contained via IO primitives.
;; Registered via register_sx_layout in __init__.py.
;; ---------------------------------------------------------------------------
;; Auto-fetching cart page header macros
;; ---------------------------------------------------------------------------
(defmacro ~cart-page-header-auto (oob)
"Cart page header: cart-row + page-cart-row using (cart-page-ctx)."
(quasiquote
(let ((__cpctx (cart-page-ctx)))
(<>
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child")
(~header-child-sx :id "cart-header-child"
:inner (~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label
:feature-image (get __cpctx "feature-image")
:title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
:oob (unquote oob)))))))
(defmacro ~cart-page-header-oob ()
"Cart page OOB: individual oob rows."
(quasiquote
(let ((__cpctx (cart-page-ctx)))
(<>
(~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label
:feature-image (get __cpctx "feature-image")
:title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
:oob true)
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child"
:oob true)))))
;; ---------------------------------------------------------------------------
;; cart-page layout: root + cart row + page-cart row
;; ---------------------------------------------------------------------------
(defcomp ~cart-page-layout-full ()
(<> (~root-header-auto)
(~header-child-sx
:inner (~cart-page-header-auto))))
(defcomp ~cart-page-layout-oob ()
(<> (~cart-page-header-oob)
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; cart-admin layout: root + post header + admin header
;; Uses (post-header-ctx) — requires :data handler to populate g._defpage_ctx
;; ---------------------------------------------------------------------------
(defcomp ~cart-admin-layout-full (&key selected)
(<> (~root-header-auto)
(~header-child-sx
:inner (~post-header-auto nil))))
(defcomp ~cart-admin-layout-oob (&key selected)
(<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected))
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; orders-within-cart: root + auth-simple + orders
;; ---------------------------------------------------------------------------
(defcomp ~cart-orders-layout-full (&key list-url)
(<> (~root-header-auto)
(~header-child-sx
:inner (<> (~auth-header-row-simple-auto)
(~header-child-sx :id "auth-header-child"
:inner (~orders-header-row :list-url list-url))))))
(defcomp ~cart-orders-layout-oob (&key list-url)
(<> (~auth-header-row-simple-auto true)
(~oob-header-sx
:parent-id "auth-header-child"
:row (~orders-header-row :list-url list-url))
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; order-detail-within-cart: root + auth-simple + orders + order
;; ---------------------------------------------------------------------------
(defcomp ~cart-order-detail-layout-full (&key list-url detail-url order-label)
(<> (~root-header-auto)
(~header-child-sx
:inner (<> (~auth-header-row-simple-auto)
(~header-child-sx :id "auth-header-child"
:inner (<> (~orders-header-row :list-url list-url)
(~header-child-sx :id "orders-header-child"
:inner (~menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url
:link-label order-label
:icon "fa fa-gbp"))))))))
(defcomp ~cart-order-detail-layout-oob (&key detail-url order-label)
(<> (~oob-header-sx
:parent-id "orders-header-child"
:row (~menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url :link-label order-label
:icon "fa fa-gbp" :oob true))
(~root-header-auto true)))
;; --- orders rows wrapper (for infinite scroll) ---
(defcomp ~cart-orders-rows (&key rows next-scroll)
(<> rows next-scroll))
;; Composition defcomp — replaces Python loop in render_orders_rows
(defcomp ~cart-orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
(~cart-orders-rows
:rows (map (lambda (od)
(~order-row-pair :order od :detail-url-prefix detail-url-prefix))
(or orders (list)))
:next-scroll (if (< page total-pages)
(~infinite-scroll :url next-url :page page
:total-pages total-pages :id-prefix "orders" :colspan 5)
(~order-end-row))))
;; Composition defcomp — replaces conditional composition in render_checkout_error_page
(defcomp ~cart-checkout-error-from-data (&key msg order-id back-url)
(~checkout-error-content
:msg msg
:order (when order-id (~checkout-error-order-id :oid (str "#" order-id)))
:back-url back-url))

View File

@@ -39,3 +39,56 @@
(defcomp ~cart-overview-panel (&key cards) (defcomp ~cart-overview-panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3" (div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" cards))) (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 (~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured :placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :sx-select "#payments-panel"))) :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,807 +0,0 @@
"""
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()``.
"""
from __future__ import annotations
import os
from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, root_header_sx, post_admin_header_sx,
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,
)
from shared.infrastructure.urls import market_product_url, cart_url
# Load cart-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="cart")
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_sx)."""
if ctx.get("post") or not page_post:
return ctx
ctx = {**ctx, "post": {
"id": getattr(page_post, "id", None),
"slug": getattr(page_post, "slug", ""),
"title": getattr(page_post, "title", ""),
"feature_image": getattr(page_post, "feature_image", None),
}}
return ctx
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present (for post header row)."""
if ctx.get("container_nav"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
slug = post.get("slug", "")
if not post_id:
return ctx
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""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)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return sx_call(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
link_label="cart", icon="fa fa-shopping-cart",
child_id="cart-header-child", oob=oob,
)
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(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(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
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,
)
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",
)
# ---------------------------------------------------------------------------
# Cart overview
# ---------------------------------------------------------------------------
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}"
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 + ")"
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)))
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
async def render_orders_page(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Full page: orders list."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
hdr = root_header_sx(ctx)
auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
)
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)
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)
async def render_orders_oob(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""OOB response for orders list."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
auth_oob = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call(
"oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)),
)
root_oob = 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)
# ---------------------------------------------------------------------------
# Public API: Single order detail
# ---------------------------------------------------------------------------
async def render_order_page(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""Full page: single order detail."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_sx(ctx)
order_row = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
order_child = sx_call(
"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)) + ")"
)) + ")"),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""OOB response for single order detail."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
)
orders_child_oob = sx_call("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error
# ---------------------------------------------------------------------------
def _checkout_error_filter_sx() -> str:
return sx_call("checkout-error-header")
def _checkout_error_content_sx(error: str | None, order: Any | None) -> str:
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}")
back_url = cart_url("/")
return sx_call(
"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)
# ---------------------------------------------------------------------------
# Page admin (/<page_slug>/admin/)
# ---------------------------------------------------------------------------
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:
"""Render the payments config panel for PUT response."""
return _cart_payments_main_panel_sx(ctx)

View File

@@ -1,13 +1,11 @@
"""Cart defpage setup — registers layouts, page helpers, and loads .sx pages.""" """Cart defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations from __future__ import annotations
from typing import Any
def setup_cart_pages() -> None: def setup_cart_pages() -> None:
"""Register cart-specific layouts, page helpers, and load page definitions.""" """Register cart-specific layouts and load page definitions."""
from .layouts import _register_cart_layouts
_register_cart_layouts() _register_cart_layouts()
_register_cart_helpers()
_load_cart_page_files() _load_cart_page_files()
@@ -15,107 +13,3 @@ def _load_cart_page_files() -> None:
import os import os
from shared.sx.pages import load_page_dir from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "cart") load_page_dir(os.path.dirname(__file__), "cart")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_cart_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("cart-page", _cart_page_full, _cart_page_oob)
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
root_hdr = root_header_sx(ctx)
child = _cart_header_sx(ctx)
page_hdr = _page_cart_header_sx(ctx, page_post)
nested = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
)
return "(<> " + root_hdr + " " + nested + ")"
def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
page_post = ctx.get("page_post")
child_oob = sx_call("oob-header-sx",
parent_id="cart-header-child",
row=SxExpr(_page_cart_header_sx(ctx, page_post)))
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
root_hdr_oob = root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _post_header_sx, _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected)
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_cart_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("cart", {
"overview-content": _h_overview_content,
"page-cart-content": _h_page_cart_content,
"cart-admin-content": _h_cart_admin_content,
"cart-payments-content": _h_cart_payments_content,
})
def _h_overview_content():
from quart import g
page_groups = getattr(g, "overview_page_groups", [])
from sx.sx_components import _overview_main_panel_sx
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach
# The function reads cart_url from ctx, which we can get from template context
from shared.sx.page import get_template_context
import asyncio
# Page helpers are sync — we pre-compute in before_request
return getattr(g, "overview_content", "")
def _h_page_cart_content():
from quart import g
return getattr(g, "page_cart_content", "")
def _h_cart_admin_content():
from sx.sx_components import _cart_admin_main_panel_sx
from shared.sx.page import get_template_context
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx
# We can pre-compute in before_request, or use get_template_context_sync-like pattern
from quart import g
return getattr(g, "cart_admin_content", "")
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")

View File

@@ -1,25 +1,44 @@
;; Cart app defpage declarations. ;; Cart app defpage declarations.
;; All data fetching via (service ...) IO primitives, no Python helpers.
(defpage cart-overview (defpage cart-overview
:path "/" :path "/"
:auth :public :auth :public
:layout :root :layout :root
:content (overview-content)) :data (service "cart-page" "overview-data")
:content (~cart-overview-content
:page-groups page-groups
:cart-url-base cart-url-base))
(defpage page-cart-view (defpage page-cart-view
:path "/" :path "/<page_slug>/"
:auth :public :auth :public
:layout :cart-page :layout :cart-page
:content (page-cart-content)) :data (service "cart-page" "page-cart-data")
:content (~cart-page-cart-content
:cart-items cart-items
:cal-entries cal-entries
:ticket-groups ticket-groups
:summary (~cart-summary-from-data
:item-count (get summary "item_count")
:grand-total (get summary "grand_total")
:symbol (get summary "symbol")
:is-logged-in (get summary "is_logged_in")
:checkout-action (get summary "checkout_action")
:login-href (get summary "login_href")
:user-email (get summary "user_email"))))
(defpage cart-admin (defpage cart-admin
:path "/" :path "/<page_slug>/admin/"
:auth :admin :auth :admin
:layout :cart-admin :layout :cart-admin
:content (cart-admin-content)) :data (service "cart-page" "admin-data")
:content (~cart-admin-content))
(defpage cart-payments (defpage cart-payments
:path "/payments/" :path "/<page_slug>/admin/payments/"
:auth :admin :auth :admin
:layout (:cart-admin :selected "payments") :layout (:cart-admin :selected "payments")
:content (cart-payments-content)) :data (service "cart-page" "payments-admin-data")
:content (~cart-payments-content
:page-config page-config))

View File

@@ -0,0 +1,8 @@
"""Cart layout registration — all layouts delegate to .sx defcomps."""
from __future__ import annotations
def _register_cart_layouts() -> None:
from shared.sx.layouts import register_sx_layout
register_sx_layout("cart-page", "cart-page-layout-full", "cart-page-layout-oob")
register_sx_layout("cart-admin", "cart-admin-layout-full", "cart-admin-layout-oob")

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

@@ -0,0 +1,121 @@
"""Cart render functions — called from bp routes."""
from __future__ import annotations
from .utils import _serialize_order, _serialize_calendar_entry
async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, full_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
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]
content = sx_call("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
header_rows = await render_to_sx_with_env("cart-orders-layout-full", {},
list_url=list_url,
)
filt = sx_call("order-list-header", search_mobile=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)
def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
from shared.sx.helpers import sx_call
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]
next_url = list_url + qs_fn(page=page + 1) if page < total_pages else ""
return sx_call("cart-orders-rows-content",
orders=order_dicts, detail_url_prefix=detail_url_prefix,
page=page, total_pages=total_pages, next_url=next_url)
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, search_desktop_sx, search_mobile_sx, oob_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
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]
content = sx_call("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
oobs = await render_to_sx_with_env("cart-orders-layout-oob", {},
list_url=list_url,
)
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
async def render_order_page(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = sx_call("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
header_rows = await render_to_sx_with_env("cart-order-detail-layout-full", {},
list_url=list_url, detail_url=detail_url,
order_label=f"Order {order.id}",
)
return await full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import sx_call, render_to_sx_with_env, oob_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = sx_call("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
oobs = await render_to_sx_with_env("cart-order-detail-layout-oob", {},
detail_url=detail_url,
order_label=f"Order {order.id}",
)
return await oob_page_sx(oobs=oobs, filter=filt, content=main)
async def render_checkout_error_page(ctx, error=None, order=None):
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
from shared.infrastructure.urls import cart_url
err_msg = error or "Unexpected error while creating the hosted checkout session."
hdr = await render_to_sx_with_env("layout-root-full", {})
filt = sx_call("checkout-error-header")
content = sx_call("cart-checkout-error-from-data",
msg=err_msg, order_id=order.id if order else None,
back_url=cart_url("/"))
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
def render_cart_payments_panel(ctx):
from shared.sx.helpers import sx_call
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 sx_call("cart-payments-content", page_config=pc_data)

40
cart/sxc/pages/utils.py Normal file
View File

@@ -0,0 +1,40 @@
"""Cart page utilities — serializers and formatters."""
from __future__ import annotations
from typing import Any
def _serialize_order(order: Any) -> dict:
from shared.infrastructure.urls import market_product_url
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
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 _serialize_calendar_entry(e: Any) -> dict:
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}"}

View File

@@ -46,6 +46,7 @@ services:
- ./blog/alembic:/app/blog/alembic:ro - ./blog/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py - ./blog/app.py:/app/app.py
- ./blog/sx:/app/sx - ./blog/sx:/app/sx
- ./blog/sxc:/app/sxc
- ./blog/bp:/app/bp - ./blog/bp:/app/bp
- ./blog/services:/app/services - ./blog/services:/app/services
- ./blog/templates:/app/templates - ./blog/templates:/app/templates
@@ -84,6 +85,7 @@ services:
- ./market/alembic:/app/market/alembic:ro - ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py - ./market/app.py:/app/app.py
- ./market/sx:/app/sx - ./market/sx:/app/sx
- ./market/sxc:/app/sxc
- ./market/bp:/app/bp - ./market/bp:/app/bp
- ./market/services:/app/services - ./market/services:/app/services
- ./market/templates:/app/templates - ./market/templates:/app/templates
@@ -121,6 +123,7 @@ services:
- ./cart/alembic:/app/cart/alembic:ro - ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py - ./cart/app.py:/app/app.py
- ./cart/sx:/app/sx - ./cart/sx:/app/sx
- ./cart/sxc:/app/sxc
- ./cart/bp:/app/bp - ./cart/bp:/app/bp
- ./cart/services:/app/services - ./cart/services:/app/services
- ./cart/templates:/app/templates - ./cart/templates:/app/templates
@@ -158,6 +161,7 @@ services:
- ./events/alembic:/app/events/alembic:ro - ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py - ./events/app.py:/app/app.py
- ./events/sx:/app/sx - ./events/sx:/app/sx
- ./events/sxc:/app/sxc
- ./events/bp:/app/bp - ./events/bp:/app/bp
- ./events/services:/app/services - ./events/services:/app/services
- ./events/templates:/app/templates - ./events/templates:/app/templates
@@ -195,6 +199,7 @@ services:
- ./federation/alembic:/app/federation/alembic:ro - ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py - ./federation/app.py:/app/app.py
- ./federation/sx:/app/sx - ./federation/sx:/app/sx
- ./federation/sxc:/app/sxc
- ./federation/bp:/app/bp - ./federation/bp:/app/bp
- ./federation/services:/app/services - ./federation/services:/app/services
- ./federation/templates:/app/templates - ./federation/templates:/app/templates
@@ -232,6 +237,7 @@ services:
- ./account/alembic:/app/account/alembic:ro - ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py - ./account/app.py:/app/app.py
- ./account/sx:/app/sx - ./account/sx:/app/sx
- ./account/sxc:/app/sxc
- ./account/bp:/app/bp - ./account/bp:/app/bp
- ./account/services:/app/services - ./account/services:/app/services
- ./account/templates:/app/templates - ./account/templates:/app/templates
@@ -331,6 +337,7 @@ services:
- ./orders/alembic:/app/orders/alembic:ro - ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py - ./orders/app.py:/app/app.py
- ./orders/sx:/app/sx - ./orders/sx:/app/sx
- ./orders/sxc:/app/sxc
- ./orders/bp:/app/bp - ./orders/bp:/app/bp
- ./orders/services:/app/services - ./orders/services:/app/services
- ./orders/templates:/app/templates - ./orders/templates:/app/templates
@@ -392,6 +399,7 @@ services:
- ./sx/bp:/app/bp - ./sx/bp:/app/bp
- ./sx/services:/app/services - ./sx/services:/app/services
- ./sx/content:/app/content - ./sx/content:/app/content
- ./sx/sx:/app/sx
- ./sx/path_setup.py:/app/path_setup.py - ./sx/path_setup.py:/app/path_setup.py
- ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh - ./sx/entrypoint.sh:/usr/local/bin/entrypoint.sh
- ./sx/__init__.py:/app/__init__.py:ro - ./sx/__init__.py:/app/__init__.py:ro

View File

@@ -105,9 +105,458 @@ Call `load_css_registry()` in `setup_sx_bridge()` after loading components.
5. Inspect `<style id="sx-css">` — should grow as new pages introduce new classes 5. Inspect `<style id="sx-css">` — should grow as new pages introduce new classes
6. Check non-sx pages still render correctly (full CSS dump fallback) 6. Check non-sx pages still render correctly (full CSS dump fallback)
## Phase 2 (Future) ## Phase 2: S-Expression Styles — Native SX Style Primitives
- **Component-level pre-computation:** Pre-scan classes per component at registration time ### Context
- **Own rule generator:** Replace tw.css parsing with a Python rule engine (no Tailwind dependency at all)
- **Header compression:** Use bitfield or hash instead of full class list SX eliminated the HTML/JS divide — code is data is DOM. But one foreign language remains: CSS. Components are full of `:class "flex gap-4 items-center p-2 bg-sky-100 rounded"` — opaque strings from a separate language (Tailwind) that requires a separate build step (Tailwind v3 CLI), a separate parser (css_registry.py parsing tw.css), and a separate delivery mechanism (hash-based dedup).
- **Critical CSS:** Only inline above-fold CSS, lazy-load rest
**Goal:** Make styles first-class SX expressions. `(css :flex :gap-4 :items-center :p-2 :bg-sky-100 :rounded)` replaces `"flex gap-4 items-center p-2 bg-sky-100 rounded"`. Same mental model as Tailwind — atomic utility keywords — but native to the language. No build step. No external CSS framework. Code = data = DOM = styles.
### Surface Syntax
```lisp
;; Before (Tailwind class strings)
(div :class "flex gap-4 items-center p-2 bg-sky-100 rounded" ...)
;; After (SX style expressions)
(div :style (css :flex :gap-4 :items-center :p-2 :bg-sky-100 :rounded) ...)
;; Responsive + pseudo-classes (variant:atom, parsed as single keyword)
(div :style (css :flex :gap-2 :sm:gap-4 :sm:flex-row :hover:bg-sky-200) ...)
;; Named styles
(defstyle card-base (css :rounded-xl :bg-white :shadow :hover:shadow-md :transition))
(div :style card-base ...)
;; Composition
(div :style (merge-styles card-base (css :p-4 :border :border-stone-200)) ...)
;; Conditional
(div :style (if active (css :bg-sky-500 :text-white) (css :bg-stone-100)) ...)
;; Both :class and :style coexist during migration
(div :class "prose" :style (css :p-4 :max-w-3xl) ...)
```
**Why `(css :flex :gap-4)` not `(flex :gap 4)` or `(style :display :flex :gap "1rem")`?**
- Keywords mirror Tailwind class names 1:1 — migration is mechanical search-replace
- Single `css` primitive, no namespace pollution (hundreds of functions like `flex`, `p`, `bg`)
- Parser already handles `:hover:bg-sky-200` as one keyword (regex `:[a-zA-Z_][a-zA-Z0-9_>:-]*`)
### Architecture
#### Three layers
1. **Style Dictionary** (`style_dict.py`) — maps keyword atoms to CSS declarations. Pure data. Replaces tw.css.
2. **Style Resolver** (`style_resolver.py`) — `(css :flex :gap-4)``StyleValue(class_name="sx-a3f2c1", declarations="display:flex;gap:1rem")`. Memoized.
3. **Style Registry** — generated CSS rules registered into the existing `css_registry.py` delivery system. Same hash-based dedup, same `<style data-sx-css>`, same `SX-Css` header.
#### Output: generated classes (not inline styles)
Inline `style="..."` can't express `:hover`, `:focus`, `@media` breakpoints, or combinators. Generated classes preserve all Tailwind functionality. The `css` primitive produces a `StyleValue` with a content-addressed class name. The renderer emits `class="sx-a3f2c1"` and registers the CSS rule for on-demand delivery.
### @ Rules (Animations, Keyframes, Containers)
`@media` breakpoints are handled via responsive variants (`:sm:flex-row`), but CSS has other @ rules that need first-class support:
#### `@keyframes` — via `defkeyframes`
```lisp
;; Define a keyframes animation
(defkeyframes fade-in
(from (css :opacity-0))
(to (css :opacity-100)))
(defkeyframes slide-up
("0%" (css :translate-y-4 :opacity-0))
("100%" (css :translate-y-0 :opacity-100)))
;; Use it — animate-[name] atom references the keyframes
(div :style (css :animate-fade-in :duration-300) ...)
```
**Implementation:** `defkeyframes` is a special form that:
1. Evaluates each step's `(css ...)` body to get declarations
2. Builds a `@keyframes fade-in { from { opacity:0 } to { opacity:1 } }` rule
3. Registers the `@keyframes` rule in `css_registry.py` via `register_generated_rule()`
4. Binds the name so `animate-fade-in` can reference it
**Built-in animations** in `style_dict.py`:
```python
# Keyframes registered at dictionary load time
KEYFRAMES: dict[str, str] = {
"spin": "@keyframes spin{to{transform:rotate(360deg)}}",
"ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}",
"pulse": "@keyframes pulse{50%{opacity:.5}}",
"bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}",
}
# Animation atoms reference keyframes by name
STYLE_ATOMS |= {
"animate-spin": "animation:spin 1s linear infinite",
"animate-ping": "animation:ping 1s cubic-bezier(0,0,0.2,1) infinite",
"animate-pulse": "animation:pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
"animate-bounce": "animation:bounce 1s infinite",
"animate-none": "animation:none",
"duration-75": "animation-duration:75ms",
"duration-100": "animation-duration:100ms",
"duration-150": "animation-duration:150ms",
"duration-200": "animation-duration:200ms",
"duration-300": "animation-duration:300ms",
"duration-500": "animation-duration:500ms",
"duration-700": "animation-duration:700ms",
"duration-1000": "animation-duration:1000ms",
}
```
When the resolver encounters `animate-spin`, it emits both the class rule AND ensures the `@keyframes spin` rule is registered. The `@keyframes` rules flow through the same `_REGISTRY``lookup_rules()``SX-Css` delta pipeline.
#### `@container` queries
```lisp
;; Container context
(div :style (css :container :container-name-sidebar) ...)
;; Container query variant (like responsive but scoped to container)
(div :style (css :flex-col :@sm/sidebar:flex-row) ...)
```
Variant prefix `@sm/sidebar``@container sidebar (min-width: 640px)`. Parsed the same way as responsive variants but emits `@container` instead of `@media`.
#### `@font-face`
Not needed as atoms — font loading stays in `basics.css` or a dedicated `(load-font ...)` primitive. Fonts are infrastructure, not component styles.
### Dynamic Class Generation
#### Static atoms (common case)
```lisp
(css :flex :gap-4 :bg-sky-100)
```
All atoms are keywords known at parse time. Server and client both resolve from the dictionary. No issues.
#### Dynamic atoms (runtime-computed)
```lisp
;; Color from data
(let ((color (get item "color")))
(div :style (css :p-4 :rounded (str "bg-" color "-100")) ...))
;; Numeric from computation
(div :style (css :flex (str "gap-" (if compact "1" "4"))) ...)
```
The `css` primitive accepts both keywords and strings. When it receives a string like `"bg-sky-100"`, it looks it up in `STYLE_ATOMS` the same way. This works on both server and client because both have the full dictionary in memory.
**No server round-trip needed** — the client has the complete style dictionary cached in localStorage. Dynamic atom lookup is a local hash table read, same as static atoms.
#### Arbitrary values (escape hatch)
For values not in the dictionary — truly custom measurements, colors, etc.:
```lisp
;; Arbitrary value syntax (mirrors Tailwind's bracket notation)
(css :w-[347px] :h-[calc(100vh-4rem)] :bg-[#ff6b35])
```
**Pattern-based generator** in the resolver (both server and client):
```python
ARBITRARY_PATTERNS: list[tuple[re.Pattern, Callable]] = [
# w-[value] → width:value
(re.compile(r"w-\[(.+)\]"), lambda v: f"width:{v}"),
# h-[value] → height:value
(re.compile(r"h-\[(.+)\]"), lambda v: f"height:{v}"),
# bg-\[value] → background-color:value
(re.compile(r"bg-\[(.+)\]"), lambda v: f"background-color:{v}"),
# p-[value] → padding:value
(re.compile(r"p-\[(.+)\]"), lambda v: f"padding:{v}"),
# text-[value] → font-size:value
(re.compile(r"text-\[(.+)\]"), lambda v: f"font-size:{v}"),
# top/right/bottom/left-[value]
(re.compile(r"(top|right|bottom|left)-\[(.+)\]"), lambda d, v: f"{d}:{v}"),
# grid-cols-[value] → grid-template-columns:value
(re.compile(r"grid-cols-\[(.+)\]"), lambda v: f"grid-template-columns:{v}"),
# min/max-w/h-[value]
(re.compile(r"(min|max)-(w|h)-\[(.+)\]"),
lambda mm, dim, v: f"{'width' if dim=='w' else 'height'}:{v}" if mm=='max' else f"min-{'width' if dim=='w' else 'height'}:{v}"),
]
```
Resolution order: dictionary lookup → pattern match → error (unknown atom).
The generator runs client-side too (it's just regex + string formatting), so arbitrary values never cause a server round-trip. The generated class and CSS rule are injected into `<style id="sx-css">` on the client, same as dictionary-resolved atoms.
#### Fully dynamic (data-driven colors/sizes)
For cases where the CSS property and value are both runtime data (e.g., user-chosen brand colors stored in the database):
```lisp
;; Inline style fallback — when value is truly unknown
(div :style (str "background-color:" brand-color) ...)
;; Or a raw-css escape hatch
(div :style (raw-css "background-color" brand-color) ...)
```
These emit inline `style="..."` attributes, bypassing the class generation system. This is correct — these values are unique per-entity, so generating a class would be wasteful (class never reused). Inline styles are the right tool for truly unique values.
### Style Delivery & Caching
#### Current system (CSS classes)
1. **Full page load**: Server scans rendered SX for class names → `lookup_rules()` gets CSS for those classes → embeds in `<style id="sx-css">` + stores hash in `<meta name="sx-css-classes">`
2. **Subsequent SX requests**: Client sends `SX-Css: {8-char-hash}` header → server resolves hash to known class set → computes delta (new classes only) → sends `<style data-sx-css>{new rules}</style>` inline in response + `SX-Css-Hash` response header with updated cumulative hash
3. **Client accumulates**: `sx.js` extracts `<style data-sx-css>` blocks, appends rules to `<style id="sx-css">`, updates its `_sxCssHash`
#### Current system (components)
- Components cached in **localStorage** by content hash
- Server checks `sx-comp-hash` cookie → if client has current hash, omits component source from response body
- Client loads from localStorage on cache hit, downloads on miss
#### New system (SX styles) — same pattern as components
**Key insight**: The style dictionary (`STYLE_ATOMS`) is a fixed dataset, like component definitions. It changes only on deployment, not per-request. Cache it in localStorage like components, not per-request like CSS class deltas.
**Server side:**
- At startup, hash the full style dictionary → `sx-style-dict-hash`
- Check `sx-style-hash` cookie on each request
- If client has current hash: omit dictionary from response
- If client is stale/missing: include `<script type="text/sx-styles" data-hash="{hash}">{serialized dict}</script>` in full-page response
- Generated CSS rules (from `(css ...)` evaluation) are tracked the same way current CSS classes are — server sends only new rules client doesn't have
**Client side (`sx.js`):**
- On full page load: check `<script type="text/sx-styles" data-hash="{hash}">`
- If hash matches localStorage `sx-styles-hash`: load from localStorage (skip download)
- If hash differs or no cache: parse inline dict, store in localStorage, set cookie
- Style dictionary lives in memory as a JS object for `css` primitive lookups
- Generated CSS rules injected into `<style id="sx-css">` (same as current system)
**Per-request style delivery** (for SX responses after initial page):
- `(css ...)` produces `StyleValue` on server → renderer emits `class="sx-a3f2c1"`
- Server registers generated rule in `_REGISTRY``lookup_rules()` picks it up
- Existing `SX-Css` hash mechanism sends only new CSS rules to client
- No change needed to the delta delivery pipeline — generated class names flow through `lookup_rules()` exactly like Tailwind class names do today
**Server-side session tracking** (optimization):
- Server maintains `dict[client_id, set[str]]` mapping client IDs to known style rule hashes
- Client ID = session cookie or device ID (already exists in rose-ash auth system)
- On each response, server records which style rules were sent to this client
- On subsequent requests, server checks its record before computing delta
- Falls back to hash-based negotiation if server-side record is missing (restart, eviction)
- This avoids the round-trip cost of the client needing to tell the server what it knows — the server already knows
**Data transfer optimization:**
- Style dictionary: ~15-20KB serialized, sent once, cached in localStorage indefinitely (until hash changes on deploy)
- Per-request: only delta CSS rules (typically 0-500 bytes for navigation to a new page type)
- Preamble (resets, FontAwesome, basics.css): sent once on full page load, same as today
- Total initial download actually decreases: style dict (~20KB) < tw.css sent as rules (~40KB+ for pages using many classes)
### Implementation Phases
#### Phase 2.0: Style Dictionary
**New file: `shared/sx/style_dict.py`**
Pure data mapping ~500 keyword atoms (the ones actually used across the codebase) to CSS declarations:
```python
STYLE_ATOMS: dict[str, str] = {
"flex": "display:flex",
"hidden": "display:none",
"block": "display:block",
"flex-col": "flex-direction:column",
"flex-row": "flex-direction:row",
"items-center": "align-items:center",
"justify-between": "justify-content:space-between",
"gap-1": "gap:0.25rem",
"gap-2": "gap:0.5rem",
"gap-4": "gap:1rem",
"p-2": "padding:0.5rem",
"px-4": "padding-left:1rem;padding-right:1rem",
"bg-sky-100": "background-color:rgb(224 242 254)",
"rounded": "border-radius:0.25rem",
"rounded-xl": "border-radius:0.75rem",
"text-sm": "font-size:0.875rem;line-height:1.25rem",
"font-semibold": "font-weight:600",
"shadow": "box-shadow:0 1px 3px 0 rgb(0 0 0/0.1),0 1px 2px -1px rgb(0 0 0/0.1)",
"transition": "transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(0.4,0,0.2,1);transition-duration:150ms",
# ... ~500 entries total
}
PSEUDO_VARIANTS: dict[str, str] = {
"hover": ":hover", "focus": ":focus", "active": ":active",
"disabled": ":disabled", "first": ":first-child", "last": ":last-child",
"group-hover": ":is(.group:hover) &",
}
RESPONSIVE_BREAKPOINTS: dict[str, str] = {
"sm": "(min-width:640px)", "md": "(min-width:768px)",
"lg": "(min-width:1024px)", "xl": "(min-width:1280px)",
}
KEYFRAMES: dict[str, str] = {
"spin": "@keyframes spin{to{transform:rotate(360deg)}}",
"ping": "@keyframes ping{75%,100%{transform:scale(2);opacity:0}}",
"pulse": "@keyframes pulse{50%{opacity:.5}}",
"bounce": "@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(0.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,0.2,1)}}",
}
# Arbitrary value patterns — fallback when atom not in STYLE_ATOMS
ARBITRARY_PATTERNS: list[tuple[str, str]] = [
# pattern → CSS template ({0} = captured value)
(r"w-\[(.+)\]", "width:{0}"),
(r"h-\[(.+)\]", "height:{0}"),
(r"bg-\[(.+)\]", "background-color:{0}"),
(r"p-\[(.+)\]", "padding:{0}"),
(r"m-\[(.+)\]", "margin:{0}"),
(r"text-\[(.+)\]", "font-size:{0}"),
(r"(top|right|bottom|left)-\[(.+)\]", "{0}:{1}"),
(r"(min|max)-(w|h)-\[(.+)\]", "{0}-{1}:{2}"),
(r"grid-cols-\[(.+)\]", "grid-template-columns:{0}"),
(r"gap-\[(.+)\]", "gap:{0}"),
]
```
Generated by: scanning all `:class "..."` across 64 .sx files to find used atoms, then extracting their CSS from the existing tw.css via `css_registry.py`'s parsed `_REGISTRY`.
#### Phase 2.1: StyleValue type + `css` primitive + resolver
**Modify: `shared/sx/types.py`** — add StyleValue:
```python
@dataclass(frozen=True)
class StyleValue:
class_name: str # "sx-a3f2c1"
declarations: str # "display:flex;gap:1rem"
media_rules: tuple = () # ((query, decls), ...)
pseudo_rules: tuple = () # ((selector, decls), ...)
keyframes: tuple = () # (("spin", "@keyframes spin{...}"), ...)
container_rules: tuple = () # (("sidebar (min-width:640px)", decls), ...)
```
**New file: `shared/sx/style_resolver.py`** — memoized resolver:
- Takes tuple of atom strings (e.g., `("flex", "gap-4", "hover:bg-sky-200", "sm:flex-row")`)
- Splits variant prefixes (`hover:bg-sky-200` → variant=`hover`, atom=`bg-sky-200`)
- Looks up declarations in STYLE_ATOMS
- Falls back to `ARBITRARY_PATTERNS` for bracket notation (`w-[347px]``width:347px`)
- Detects `animate-*` atoms → includes associated `@keyframes` rules
- Groups into base / pseudo / media / keyframes / container
- Hashes declarations → deterministic class name `sx-{hash[:6]}`
- Returns `StyleValue`
- Dict cache keyed on input tuple
- Accepts both keywords and runtime strings (for dynamic atom construction)
**Modify: `shared/sx/primitives.py`** — add `css` and `merge-styles`:
```python
@register_primitive("css")
def prim_css(*args):
from .style_resolver import resolve_style
return resolve_style(tuple(str(a) for a in args if a))
@register_primitive("merge-styles")
def prim_merge_styles(*styles):
from .style_resolver import merge_styles
return merge_styles([s for s in styles if isinstance(s, StyleValue)])
```
#### Phase 2.2: Server-side rendering + delivery integration
**Modify: `shared/sx/html.py`** — in `_render_element()` (line ~482):
- When `:style` evaluates to a `StyleValue`: emit its `class_name` as a CSS class (appended to any existing `:class`), register the rule with `register_generated_rule()`, don't emit `:style` attribute
- When `:style` is a string: existing behavior (inline style attribute)
**Modify: `shared/sx/async_eval.py`** — same change in `_arender_element()` (line ~641)
**Modify: `shared/sx/css_registry.py`** — add `register_generated_rule(style_val)`:
- Builds CSS rule: `.sx-a3f2c1{display:flex;gap:1rem}`
- Plus pseudo rules: `.sx-a3f2c1:hover{background-color:...}`
- Plus media rules: `@media(min-width:640px){.sx-a3f2c1{flex-direction:row}}`
- Inserts into `_REGISTRY` so existing `lookup_rules()` works transparently
- Generated rules flow through the same `SX-Css` hash delta mechanism — no new delivery protocol needed
**Modify: `shared/sx/helpers.py`** — style dictionary delivery:
- In `sx_page_shell()` (full page): include style dictionary as `<script type="text/sx-styles" data-hash="{hash}">` with localStorage caching (same pattern as component caching)
- Check `sx-style-hash` cookie: if client has current hash, omit dictionary source
- In `sx_response()` (SX fragment responses): no change — generated CSS rules already flow through `<style data-sx-css>`
**Modify: `shared/infrastructure/factory.py`** — add `sx-style-hash` to allowed headers in CORS config
#### Phase 2.3: Client-side (sx.js)
**Modify: `shared/static/scripts/sx.js`**:
- Add `StyleValue` type (`{_style: true, className, declarations, pseudoRules, mediaRules}`)
- Add `css` primitive to PRIMITIVES (accepts both keywords and dynamic strings)
- Add resolver logic (split variants, lookup from in-memory dict, arbitrary pattern fallback, hash, memoize)
- In `renderElement()`: when `:style` value is StyleValue, add className to element and inject CSS rule into `<style id="sx-css">` (same target as server-sent rules)
- Add `merge-styles` primitive
- Add `defstyle` to SPECIAL_FORMS
- Add style dictionary localStorage caching (same pattern as components):
- On init: check `<script type="text/sx-styles" data-hash="{hash}">`
- Cache hit (hash matches localStorage): load dict from localStorage, skip inline parse
- Cache miss: parse inline dict, store in localStorage, set `sx-style-hash` cookie
- Dict lives in `_styleAtoms` var for `css` primitive to look up at render time
**No separate `sx-styles.js`** — the style dictionary is delivered inline in the full-page shell (like components) and cached in localStorage. No extra HTTP request.
#### Phase 2.4: `defstyle` and `defkeyframes` special forms
**Modify: `shared/sx/evaluator.py`** — add `defstyle` and `defkeyframes`:
```lisp
(defstyle card-base (css :rounded-xl :bg-white :shadow))
(defkeyframes fade-in
(from (css :opacity-0))
(to (css :opacity-100)))
```
`defstyle`: evaluates the body → StyleValue, binds to name in env. Essentially `define` but semantically distinct for tooling.
`defkeyframes`: evaluates each step's `(css ...)` body, builds a `@keyframes` CSS rule, registers it via `register_generated_rule()`, and binds the animation name so `animate-[name]` atoms can reference it.
**Mirror in `shared/sx/async_eval.py`** and `sx.js`.
#### Phase 2.5: Migration tooling + gradual conversion
**New: `shared/sx/tools/class_to_css.py`** — converter script:
- `:class "flex gap-4 p-2"``:style (css :flex :gap-4 :p-2)`
- `(str "base " conditional)` → leave as `:class` or split into static `:style` + dynamic `:class`
- `(if cond "classes-a" "classes-b")``(if cond (css :classes-a) (css :classes-b))`
**Dynamic class construction** (2-3 occurrences in `layout.sx`):
- `(str "bg-" c "-" shade)``(css (str "bg-" c "-" shade))``css` accepts runtime strings, resolves from dictionary client-side (no server round-trip)
- Truly unique values (user brand colors from DB) → inline `style="..."` or `(raw-css "background-color" brand-color)`
#### Phase 2.6: Remove Tailwind
- Delete `tailwind.config.js`, remove tw.css build step
- Remove tw.css parsing from `load_css_registry()`
- Keep extra CSS (basics.css, cards.css, blog-content.css, FontAwesome)
- `css_registry.py` becomes pure runtime registry for generated + extra CSS
### Phase 2 Files
| File | Change |
|------|--------|
| `shared/sx/style_dict.py` | **New** — keyword → CSS declaration mapping (~500 atoms) |
| `shared/sx/style_resolver.py` | **New** — resolve (css ...) → StyleValue, memoized |
| `shared/sx/types.py` | Add `StyleValue` dataclass |
| `shared/sx/primitives.py` | Add `css`, `merge-styles` primitives |
| `shared/sx/html.py` | Handle StyleValue in `:style` attribute rendering |
| `shared/sx/async_eval.py` | Same StyleValue handling in async render path |
| `shared/sx/css_registry.py` | Add `register_generated_rule()` |
| `shared/sx/helpers.py` | Style dict delivery in page shell, cookie check, localStorage caching protocol |
| `shared/sx/evaluator.py` | Add `defstyle` special form |
| `shared/infrastructure/factory.py` | Add `sx-style-hash` cookie/header to CORS |
| `shared/static/scripts/sx.js` | StyleValue, css/merge-styles, defstyle, dict caching, style injection |
| `shared/sx/tools/class_to_css.py` | **New** — migration converter |
### Phase 2 Verification
- **Phase 2.1**: Unit test — `(css :flex :gap-4 :p-2)` returns correct StyleValue
- **Phase 2.2**: Render test — `(div :style (css :flex :gap-4))``<div class="sx-a3f2c1">` + CSS rule registered
- **Phase 2.3**: Browser test — client renders `:style (css ...)` with injected `<style>` rules
- **Phase 2.5**: Convert one .sx file, diff HTML output to verify identical rendering
- **Throughout**: existing `:class "..."` continues to work unchanged

View File

@@ -396,3 +396,51 @@ If performance ever becomes a concern, WASM is the escape hatch at three levels:
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. 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. 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.

65
events/actions.sx Normal file
View File

@@ -0,0 +1,65 @@
;; Events service — inter-service action endpoints
;;
;; Each defaction replaces a Python handler in bp/actions/routes.py.
;; The (service ...) primitive calls the registered CalendarService method
;; with g.s (async session) + keyword args.
(defaction adjust-ticket-quantity (&key entry-id count user-id session-id ticket-type-id)
"Add or remove tickets for a calendar entry."
(do
(service "calendar" "adjust-ticket-quantity"
:entry-id entry-id :count count
:user-id user-id :session-id session-id
:ticket-type-id ticket-type-id)
{"ok" true}))
(defaction claim-entries-for-order (&key order-id user-id session-id page-post-id)
"Claim pending calendar entries for an order."
(do
(service "calendar" "claim-entries-for-order"
:order-id order-id :user-id user-id
:session-id session-id :page-post-id page-post-id)
{"ok" true}))
(defaction claim-tickets-for-order (&key order-id user-id session-id page-post-id)
"Claim pending tickets for an order."
(do
(service "calendar" "claim-tickets-for-order"
:order-id order-id :user-id user-id
:session-id session-id :page-post-id page-post-id)
{"ok" true}))
(defaction confirm-entries-for-order (&key order-id user-id session-id)
"Confirm calendar entries after payment."
(do
(service "calendar" "confirm-entries-for-order"
:order-id order-id :user-id user-id :session-id session-id)
{"ok" true}))
(defaction confirm-tickets-for-order (&key order-id)
"Confirm tickets after payment."
(do
(service "calendar" "confirm-tickets-for-order" :order-id order-id)
{"ok" true}))
(defaction toggle-entry-post (&key entry-id content-type content-id)
"Toggle association between a calendar entry and a content item."
(let ((is-associated (service "calendar" "toggle-entry-post"
:entry-id entry-id
:content-type content-type
:content-id content-id)))
{"is_associated" is-associated}))
(defaction adopt-entries-for-user (&key user-id session-id)
"Transfer anonymous calendar entries to a logged-in user."
(do
(service "calendar" "adopt-entries-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))
(defaction adopt-tickets-for-user (&key user-id session-id)
"Transfer anonymous tickets to a logged-in user."
(do
(service "calendar" "adopt-tickets-for-user"
:user-id user-id :session-id session-id)
{"ok" true}))

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path from pathlib import Path
from quart import g, abort, request from quart import g, abort, request
@@ -9,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app 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: async def events_context() -> dict:
@@ -112,7 +111,9 @@ def create_app() -> "Quart":
url_prefix="/<slug>/markets", 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_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())
@@ -171,19 +172,25 @@ def create_app() -> "Quart":
"markets": markets, "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 # Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as register_tickets from bp.tickets.routes import register as register_tickets
tickets_bp = 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) app.register_blueprint(tickets_bp)
# Ticket admin — check-in interface (admin only) # Ticket admin — check-in interface (admin only)
from bp.ticket_admin.routes import register as register_ticket_admin from bp.ticket_admin.routes import register as register_ticket_admin
ticket_admin_bp = register_ticket_admin() ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
app.register_blueprint(ticket_admin_bp) 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 --- # --- oEmbed endpoint ---
@app.get("/oembed") @app.get("/oembed")
async def 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 .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets from .markets.routes import register as register_markets
from .page.routes import register as register_page from .page.routes import register as register_page
from .fragments import register_fragments
from .actions import register_actions from .actions import register_actions
from .data import register_data from .data import register_data

View File

@@ -1,139 +1,15 @@
"""Events app action endpoints. """Events app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for All actions are defined declaratively in ``events/actions.sx`` and
cross-app callers (cart, blog) via the internal action client. dispatched via the sx query registry. No Python fallbacks needed.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from shared.infrastructure.query_blueprint import create_action_blueprint
from shared.infrastructure.actions import ACTION_HEADER from quart import Blueprint
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions") bp, _handlers = create_action_blueprint("events")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- adjust-ticket-quantity ---
async def _adjust_ticket_quantity():
data = await request.get_json()
await services.calendar.adjust_ticket_quantity(
g.s,
data["entry_id"],
data["count"],
user_id=data.get("user_id"),
session_id=data.get("session_id"),
ticket_type_id=data.get("ticket_type_id"),
)
return {"ok": True}
_handlers["adjust-ticket-quantity"] = _adjust_ticket_quantity
# --- claim-entries-for-order ---
async def _claim_entries():
data = await request.get_json()
await services.calendar.claim_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-entries-for-order"] = _claim_entries
# --- claim-tickets-for-order ---
async def _claim_tickets():
data = await request.get_json()
await services.calendar.claim_tickets_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-tickets-for-order"] = _claim_tickets
# --- confirm-entries-for-order ---
async def _confirm_entries():
data = await request.get_json()
await services.calendar.confirm_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
)
return {"ok": True}
_handlers["confirm-entries-for-order"] = _confirm_entries
# --- confirm-tickets-for-order ---
async def _confirm_tickets():
data = await request.get_json()
await services.calendar.confirm_tickets_for_order(
g.s, data["order_id"],
)
return {"ok": True}
_handlers["confirm-tickets-for-order"] = _confirm_tickets
# --- toggle-entry-post ---
async def _toggle_entry_post():
data = await request.get_json()
is_associated = await services.calendar.toggle_entry_post(
g.s,
data["entry_id"],
data["content_type"],
data["content_id"],
)
return {"is_associated": is_associated}
_handlers["toggle-entry-post"] = _toggle_entry_post
# --- adopt-entries-for-user ---
async def _adopt_entries():
data = await request.get_json()
await services.calendar.adopt_entries_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-entries-for-user"] = _adopt_entries
# --- adopt-tickets-for-user ---
async def _adopt_tickets():
data = await request.get_json()
await services.calendar.adopt_tickets_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-tickets-for-user"] = _adopt_tickets
return bp return bp

View File

@@ -11,7 +11,7 @@ Routes:
""" """
from __future__ import annotations 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.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response from shared.sx.helpers import sx_response
@@ -67,7 +67,7 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page) entries, has_more, pending_tickets, page_info = await _load_entries(page)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_all_events_page, render_all_events_oob from sxc.pages.renders import render_all_events_page, render_all_events_oob
ctx = await get_template_context() ctx = await get_template_context()
if is_htmx_request(): if is_htmx_request():
@@ -84,8 +84,8 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page) entries, has_more, pending_tickets, page_info = await _load_entries(page)
from sx.sx_components import render_all_events_cards from sxc.pages.renders import render_all_events_cards
sx_src = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view) sx_src = render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
return sx_response(sx_src) return sx_response(sx_src)
@bp.post("/all-tickets/adjust") @bp.post("/all-tickets/adjust")
@@ -125,7 +125,7 @@ def register() -> Blueprint:
if ident["session_id"] is not None: if ident["session_id"] is not None:
frag_params["session_id"] = ident["session_id"] frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget from sxc.pages.tickets import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust") widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust")
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(widget_html + (mini_html or "")) return sx_response(widget_html + (mini_html or ""))

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, Blueprint, g Blueprint, g, request,
) )
@@ -15,22 +15,10 @@ from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("admin", __name__, url_prefix='/admin') bp = Blueprint("admin", __name__, url_prefix='/admin')
@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/") @bp.get("/description/")
@require_admin @require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs): async def calendar_description_edit(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description_edit from sxc.pages.renders import render_calendar_description_edit
html = render_calendar_description_edit(g.calendar) html = render_calendar_description_edit(g.calendar)
return sx_response(html) return sx_response(html)
@@ -46,7 +34,7 @@ def register():
g.calendar.description = description g.calendar.description = description
await g.s.flush() await g.s.flush()
from sx.sx_components import render_calendar_description from sxc.pages.renders import render_calendar_description
html = render_calendar_description(g.calendar, oob=True) html = render_calendar_description(g.calendar, oob=True)
return sx_response(html) return sx_response(html)
@@ -54,7 +42,7 @@ def register():
@bp.get("/description/view/") @bp.get("/description/view/")
@require_admin @require_admin
async def calendar_description_view(calendar_slug: str, **kwargs): async def calendar_description_view(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description from sxc.pages.renders import render_calendar_description
html = render_calendar_description(g.calendar) html = render_calendar_description(g.calendar)
return sx_response(html) return sx_response(html)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession request, make_response, Blueprint, g, abort, session as qsession
) )
@@ -158,7 +158,7 @@ def register():
confirmed_entries = visible.confirmed_entries confirmed_entries = visible.confirmed_entries
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_calendar_page, render_calendar_oob from sxc.pages.renders import render_calendar_page, render_calendar_oob
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(dict( tctx.update(dict(
@@ -199,7 +199,7 @@ def register():
await update_calendar_description(g.calendar, description) await update_calendar_description(g.calendar, description)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html from sxc.pages.calendar import _calendar_admin_main_panel_html
ctx = await get_template_context() ctx = await get_template_context()
html = _calendar_admin_main_panel_html(ctx) html = _calendar_admin_main_panel_html(ctx)
return sx_response(html) return sx_response(html)
@@ -218,13 +218,13 @@ def register():
# If we have post context (blog-embedded mode), update nav # If we have post context (blog-embedded mode), update nav
post_data = getattr(g, "post_data", None) post_data = getattr(g, "post_data", None)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel from sxc.pages.renders import render_calendars_list_panel
ctx = await get_template_context() ctx = await get_template_context()
html = render_calendars_list_panel(ctx) html = render_calendars_list_panel(ctx)
if post_data: if post_data:
from shared.services.entry_associations import get_associated_entries from shared.services.entry_associations import get_associated_entries
from sx.sx_components import render_post_nav_entries_oob from sxc.pages.entries import render_post_nav_entries_oob
post_id = (post_data.get("post") or {}).get("id") post_id = (post_data.get("post") or {}).get("id")
cals = ( cals = (

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select from sqlalchemy import select
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, make_response, Blueprint, g, jsonify
) )
from ..calendar_entries.services.entries import ( from ..calendar_entries.services.entries import (
svc_update_entry, svc_update_entry,
@@ -111,7 +111,7 @@ def register():
) )
# Render OOB nav # Render OOB nav
from sx.sx_components import render_day_entries_nav_oob from sxc.pages.entries import render_day_entries_nav_oob
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date) return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
async def get_post_nav_oob(entry_id: int): async def get_post_nav_oob(entry_id: int):
@@ -148,7 +148,7 @@ def register():
).scalars().all() ).scalars().all()
# Render OOB nav for this post # Render OOB nav for this post
from sx.sx_components import render_post_nav_entries_oob from sxc.pages.entries import render_post_nav_entries_oob
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post) nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post)
nav_oobs.append(nav_oob) nav_oobs.append(nav_oob)
@@ -238,19 +238,6 @@ def register():
"user_ticket_counts_by_type": user_ticket_counts_by_type, "user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav": container_nav, "container_nav": container_nav,
} }
@bp.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/") @bp.get("/edit/")
@require_admin @require_admin
async def get_edit(entry_id: int, **rest): async def get_edit(entry_id: int, **rest):
@@ -269,7 +256,7 @@ def register():
result = await g.s.execute(stmt) result = await g.s.execute(stmt)
day_slots = list(result.scalars()) day_slots = list(result.scalars())
from sx.sx_components import render_entry_edit_form from sxc.pages.entries import render_entry_edit_form
return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots)) return sx_response(render_entry_edit_form(g.entry, g.calendar, day, month, year, day_slots))
@bp.put("/") @bp.put("/")
@@ -433,7 +420,7 @@ def register():
nav_oob = await get_day_nav_oob(year, month, day) nav_oob = await get_day_nav_oob(year, month, day)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import _entry_main_panel_html from sxc.pages.entries import _entry_main_panel_html
tctx = await get_template_context() tctx = await get_template_context()
html = _entry_main_panel_html(tctx) html = _entry_main_panel_html(tctx)
@@ -461,7 +448,7 @@ def register():
# Re-read entry to get updated state # Re-read entry to get updated state
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned from sxc.pages.entries import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year) html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob) return sx_response(html + day_nav_oob + post_nav_oob)
@@ -486,7 +473,7 @@ def register():
# Re-read entry to get updated state # Re-read entry to get updated state
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned from sxc.pages.entries import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year) html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob) return sx_response(html + day_nav_oob + post_nav_oob)
@@ -511,7 +498,7 @@ def register():
# Re-read entry to get updated state # Re-read entry to get updated state
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_optioned from sxc.pages.entries import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year) html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return sx_response(html + day_nav_oob + post_nav_oob) return sx_response(html + day_nav_oob + post_nav_oob)
@@ -555,7 +542,7 @@ def register():
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...") # Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
await g.s.refresh(g.entry) await g.s.refresh(g.entry)
from sx.sx_components import render_entry_tickets_config from sxc.pages.entries 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 = 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) return sx_response(html)
@@ -571,7 +558,7 @@ def register():
total_pages = math.ceil(total / per_page) if total > 0 else 0 total_pages = math.ceil(total / per_page) if total > 0 else 0
va = request.view_args or {} va = request.view_args or {}
from sx.sx_components import render_post_search_results from sxc.pages.entries import render_post_search_results
return sx_response(render_post_search_results( return sx_response(render_post_search_results(
search_posts, query, page, total_pages, search_posts, query, page, total_pages,
g.entry, g.calendar, g.entry, g.calendar,
@@ -605,7 +592,7 @@ def register():
entry_posts = await get_entry_posts(g.s, entry_id) entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update # Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {} 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")) 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) nav_oob = render_entry_posts_nav_oob(entry_posts)
@@ -627,7 +614,7 @@ def register():
entry_posts = await get_entry_posts(g.s, entry_id) entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update # Return updated posts list + OOB nav update
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob from sxc.pages.entries import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {} 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")) 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) nav_oob = render_entry_posts_nav_oob(entry_posts)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, make_response, Blueprint, g
) )
from sqlalchemy import select from sqlalchemy import select
@@ -32,7 +32,7 @@ def register():
@cache_page(tag="calendars") @cache_page(tag="calendars")
async def home(**kwargs): async def home(**kwargs):
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_page, render_calendars_oob from sxc.pages.renders import render_calendars_page, render_calendars_oob
ctx = await get_template_context() ctx = await get_template_context()
if not is_htmx_request(): if not is_htmx_request():
@@ -67,14 +67,14 @@ def register():
return await make_response(render_comp("error-inline", message=str(e)), 422) return await make_response(render_comp("error-inline", message=str(e)), 422)
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel from sxc.pages.renders import render_calendars_list_panel
ctx = await get_template_context() ctx = await get_template_context()
html = render_calendars_list_panel(ctx) html = render_calendars_list_panel(ctx)
# Blog-embedded mode: also update post nav # Blog-embedded mode: also update post nav
if post_data: if post_data:
from shared.services.entry_associations import get_associated_entries from shared.services.entry_associations import get_associated_entries
from sx.sx_components import render_post_nav_entries_oob from sxc.pages.entries import render_post_nav_entries_oob
cals = ( cals = (
await g.s.execute( await g.s.execute(

View File

@@ -1,148 +1,14 @@
"""Events app data endpoints. """Events app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for All queries are defined in ``events/queries.sx``.
cross-app callers via the internal data client.
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER from shared.infrastructure.query_blueprint import create_data_blueprint
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
def register() -> Blueprint: def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data") bp, _handlers = create_data_blueprint("events")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- pending-entries ---
async def _pending_entries():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
entries = await services.calendar.pending_entries(
g.s, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["pending-entries"] = _pending_entries
# --- pending-tickets ---
async def _pending_tickets():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
tickets = await services.calendar.pending_tickets(
g.s, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(t) for t in tickets]
_handlers["pending-tickets"] = _pending_tickets
# --- entries-for-page ---
async def _entries_for_page():
page_id = request.args.get("page_id", type=int)
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
entries = await services.calendar.entries_for_page(
g.s, page_id, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["entries-for-page"] = _entries_for_page
# --- tickets-for-page ---
async def _tickets_for_page():
page_id = request.args.get("page_id", type=int)
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
tickets = await services.calendar.tickets_for_page(
g.s, page_id, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(t) for t in tickets]
_handlers["tickets-for-page"] = _tickets_for_page
# --- entries-for-order ---
async def _entries_for_order():
order_id = request.args.get("order_id", type=int)
entries = await services.calendar.get_entries_for_order(g.s, order_id)
return [dto_to_dict(e) for e in entries]
_handlers["entries-for-order"] = _entries_for_order
# --- tickets-for-order ---
async def _tickets_for_order():
order_id = request.args.get("order_id", type=int)
tickets = await services.calendar.get_tickets_for_order(g.s, order_id)
return [dto_to_dict(t) for t in tickets]
_handlers["tickets-for-order"] = _tickets_for_order
# --- entry-ids-for-content ---
async def _entry_ids_for_content():
content_type = request.args.get("content_type", "")
content_id = request.args.get("content_id", type=int)
ids = await services.calendar.entry_ids_for_content(g.s, content_type, content_id)
return list(ids)
_handlers["entry-ids-for-content"] = _entry_ids_for_content
# --- associated-entries ---
async def _associated_entries():
content_type = request.args.get("content_type", "")
content_id = request.args.get("content_id", type=int)
page = request.args.get("page", 1, type=int)
entries, has_more = await services.calendar.associated_entries(
g.s, content_type, content_id, page,
)
return {"entries": [dto_to_dict(e) for e in entries], "has_more": has_more}
_handlers["associated-entries"] = _associated_entries
# --- calendars-for-container ---
async def _calendars_for_container():
container_type = request.args.get("type", "")
container_id = request.args.get("id", type=int)
calendars = await services.calendar.calendars_for_container(
g.s, container_type, container_id,
)
return [dto_to_dict(c) for c in calendars]
_handlers["calendars-for-container"] = _calendars_for_container
# --- visible-entries-for-period ---
async def _visible_entries_for_period():
from datetime import datetime
calendar_id = request.args.get("calendar_id", type=int)
period_start = datetime.fromisoformat(request.args.get("period_start", ""))
period_end = datetime.fromisoformat(request.args.get("period_end", ""))
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
# is_admin determined server-side, never from client params
is_admin = False
entries = await services.calendar.visible_entries_for_period(
g.s, calendar_id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["visible-entries-for-period"] = _visible_entries_for_period
return bp return bp

View File

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

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone, date, timedelta from datetime import datetime, timezone, date, timedelta
from quart import ( 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 from bp.calendar.services import get_visible_entries_for_period
@@ -123,7 +123,7 @@ def register():
- pending only for current user/session - pending only for current user/session
""" """
from shared.sx.page import get_template_context from shared.sx.page import get_template_context
from sx.sx_components import render_day_page, render_day_oob from sxc.pages.renders import render_day_page, render_day_oob
tctx = await get_template_context() tctx = await get_template_context()
if not is_htmx_request(): if not is_htmx_request():

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