diff --git a/.claude/plans/hazy-sniffing-sphinx.md b/.claude/plans/hazy-sniffing-sphinx.md new file mode 100644 index 0000000..071b175 --- /dev/null +++ b/.claude/plans/hazy-sniffing-sphinx.md @@ -0,0 +1,325 @@ +# Split Cart into Microservices + +## Context +The cart app currently owns too much: CartItem, Order/OrderItem, PageConfig, ContainerRelation, plus all checkout/payment logic. We're splitting it into 4 pieces: + +1. **Relations service** — internal only, owns ContainerRelation +2. **Likes service** — internal only, unified generic likes replacing ProductLike + PostLike +3. **PageConfig → blog** — move to blog (which already owns pages) +4. **Orders service** — public (orders.rose-ash.com), owns Order/OrderItem + SumUp checkout + +After the split, cart becomes a thin CartItem CRUD + inbox service. + +--- + +## Phase 1: Relations Service (internal only) + +### 1.1 Scaffold `relations/` +Create minimal internal-only app (no templates, no context_fn): + +| File | Notes | +|------|-------| +| `relations/__init__.py` | Empty | +| `relations/path_setup.py` | Copy from cart | +| `relations/app.py` | `create_base_app("relations")`, register data + actions BPs only | +| `relations/services/__init__.py` | Empty `register_domain_services()` | +| `relations/models/__init__.py` | `from shared.models.container_relation import ContainerRelation` | +| `relations/bp/__init__.py` | Export `register_data`, `register_actions` | +| `relations/bp/data/routes.py` | Move `get-children` handler from `cart/bp/data/routes.py:175-198` | +| `relations/bp/actions/routes.py` | Move `attach-child` + `detach-child` from `cart/bp/actions/routes.py:112-153` | +| `relations/alembic.ini` | Copy from cart, adjust path | +| `relations/alembic/env.py` | MODELS=`["shared.models.container_relation"]`, TABLES=`{"container_relations"}` | +| `relations/alembic/versions/0001_initial.py` | Create `container_relations` table | +| `relations/Dockerfile` | Follow cart pattern, `COPY relations/ ./` | +| `relations/entrypoint.sh` | Standard pattern, db=`db_relations` | + +### 1.2 Retarget callers (`"cart"` → `"relations"`) + +| File | Lines | Change | +|------|-------|--------| +| `events/bp/calendars/services/calendars.py` | 74, 111, 121 | `call_action("cart", ...)` → `call_action("relations", ...)` | +| `blog/bp/menu_items/services/menu_items.py` | 83, 137, 141, 157 | Same | +| `shared/services/market_impl.py` | 96, 109, 133 | Same | + +### 1.3 Clean up cart +- Remove `get-children` from `cart/bp/data/routes.py:175-198` +- Remove `attach-child`, `detach-child` from `cart/bp/actions/routes.py:112-153` +- Remove `"shared.models.container_relation"` and `"container_relations"` from `cart/alembic/env.py` + +--- + +## Phase 2: Likes Service (internal only) + +### 2.1 New unified model +Single `likes` table in `db_likes`: + +```python +class Like(Base): + __tablename__ = "likes" + id: Mapped[int] (pk) + user_id: Mapped[int] (not null, indexed) + target_type: Mapped[str] (String 32, not null) # "product" or "post" + target_slug: Mapped[str | None] (String 255) # for products + target_id: Mapped[int | None] (Integer) # for posts + created_at, updated_at, deleted_at + + UniqueConstraint("user_id", "target_type", "target_slug") + UniqueConstraint("user_id", "target_type", "target_id") + Index("ix_likes_target", "target_type", "target_slug") +``` + +Products use `target_type="product"`, `target_slug=slug`. Posts use `target_type="post"`, `target_id=post.id`. + +### 2.2 Scaffold `likes/` + +| File | Notes | +|------|-------| +| `likes/__init__.py` | Empty | +| `likes/path_setup.py` | Standard | +| `likes/app.py` | Internal-only, `create_base_app("likes")`, data + actions BPs | +| `likes/services/__init__.py` | Empty `register_domain_services()` | +| `likes/models/__init__.py` | Import Like | +| `likes/models/like.py` | Generic Like model (above) | +| `likes/bp/__init__.py` | Export register functions | +| `likes/bp/data/routes.py` | `is-liked`, `liked-slugs`, `liked-ids` | +| `likes/bp/actions/routes.py` | `toggle` action | +| `likes/alembic.ini` | Standard | +| `likes/alembic/env.py` | MODELS=`["likes.models.like"]`, TABLES=`{"likes"}` | +| `likes/alembic/versions/0001_initial.py` | Create `likes` table | +| `likes/Dockerfile` | Standard pattern | +| `likes/entrypoint.sh` | Standard, db=`db_likes` | + +### 2.3 Data endpoints (`likes/bp/data/routes.py`) +- `is-liked`: params `user_id, target_type, target_slug/target_id` → `{"liked": bool}` +- `liked-slugs`: params `user_id, target_type` → `["slug1", "slug2"]` +- `liked-ids`: params `user_id, target_type` → `[1, 2, 3]` + +### 2.4 Action endpoints (`likes/bp/actions/routes.py`) +- `toggle`: payload `{user_id, target_type, target_slug?, target_id?}` → `{"liked": bool}` + +### 2.5 Retarget market app + +**`market/bp/product/routes.py`** (like_toggle, ~line 119): +Replace `toggle_product_like(g.s, user_id, product_slug)` with: +```python +result = await call_action("likes", "toggle", payload={ + "user_id": user_id, "target_type": "product", "target_slug": product_slug +}) +liked = result["liked"] +``` + +**`market/bp/browse/services/db_backend.py`** (most complex): +- `db_product_full` / `db_product_full_id`: Replace `ProductLike` subquery with `fetch_data("likes", "is-liked", ...)`. Annotate `is_liked` after query. +- `db_products_nocounts` / `db_products_counts`: Fetch `liked_slugs` once via `fetch_data("likes", "liked-slugs", ...)`, filter `Product.slug.in_(liked_slugs)` for `?liked=true`, annotate `is_liked` post-query. + +**Delete**: `toggle_product_like` from `market/bp/product/services/product_operations.py` + +### 2.6 Retarget blog app + +**`blog/bp/post/routes.py`** (like_toggle): +Replace `toggle_post_like(g.s, user_id, post_id)` with `call_action("likes", "toggle", payload={...})`. + +**Delete**: `toggle_post_like` from `blog/bp/post/services/post_operations.py` + +### 2.7 Remove old like models +- Remove `ProductLike` from `shared/models/market.py` (lines 118-131) + `Product.likes` relationship (lines 110-114) +- Remove `PostLike` from `shared/models/ghost_content.py` + `Post.likes` relationship +- Remove `product_likes` from market alembic TABLES +- Remove `post_likes` from blog alembic TABLES + +--- + +## Phase 3: PageConfig → Blog + +### 3.1 Replace blog proxy endpoints with direct DB queries + +**`blog/bp/data/routes.py`** (lines 77-102): Replace the 3 proxy handlers that currently call `fetch_data("cart", ...)` with direct DB queries. Copy logic from `cart/bp/data/routes.py`: +- `page-config` (cart lines 114-134) +- `page-config-by-id` (cart lines 136-149) +- `page-configs-batch` (cart lines 151-172) +- `page-config-ensure` (cart lines 49-81) — add new + +Also add the `_page_config_dict` helper (cart lines 203-213). + +### 3.2 Move action to blog + +**`blog/bp/actions/routes.py`** (~line 40): Replace `call_action("cart", "update-page-config", ...)` proxy with direct handler. Copy logic from `cart/bp/actions/routes.py:51-110`. + +### 3.3 Blog callers become local + +| File | Current | After | +|------|---------|-------| +| `blog/bp/post/admin/routes.py:34` | `fetch_data("cart", "page-config", ...)` | Direct DB query (blog now owns table) | +| `blog/bp/post/admin/routes.py:87,132` | `call_action("cart", "update-page-config", ...)` | Direct call to local handler | +| `blog/bp/post/services/markets.py:44` | `fetch_data("cart", "page-config", ...)` | Direct DB query | +| `blog/bp/blog/ghost_db.py:295` | `fetch_data("cart", "page-configs-batch", ...)` | Direct DB query | + +### 3.4 Retarget cross-service callers (`"cart"` → `"blog"`) + +| File | Change | +|------|--------| +| `cart/bp/cart/services/page_cart.py:181` | `fetch_data("cart", "page-configs-batch", ...)` → `fetch_data("blog", "page-configs-batch", ...)` | +| `cart/bp/cart/global_routes.py:274` | `fetch_data("cart", "page-config-by-id", ...)` → `fetch_data("blog", "page-config-by-id", ...)` | + +(Note: `checkout.py:117` and `cart/app.py:177` already target `"blog"`) + +### 3.5 Update blog alembic +**`blog/alembic/env.py`**: Add `"shared.models.page_config"` to MODELS and `"page_configs"` to TABLES. + +### 3.6 Clean up cart +- Remove all `page-config*` handlers from `cart/bp/data/routes.py` (lines 49-172) +- Remove `update-page-config` from `cart/bp/actions/routes.py` (lines 50-110) +- Remove `"shared.models.page_config"` and `"page_configs"` from `cart/alembic/env.py` + +--- + +## Phase 4: Orders Service (public, orders.rose-ash.com) + +### 4.1 Scaffold `orders/` + +| File | Notes | +|------|-------| +| `orders/__init__.py` | Empty | +| `orders/path_setup.py` | Standard | +| `orders/app.py` | Public app with `context_fn`, templates, fragments, page slug hydration | +| `orders/services/__init__.py` | `register_domain_services()` | +| `orders/models/__init__.py` | `from shared.models.order import Order, OrderItem` | +| `orders/bp/__init__.py` | Export all BPs | +| `orders/bp/order/` | Move from `cart/bp/order/` (single order: detail, pay, recheck) | +| `orders/bp/orders/` | Move from `cart/bp/orders/` (order list + pagination) | +| `orders/bp/checkout/routes.py` | Webhook + return routes from `cart/bp/cart/global_routes.py` | +| `orders/bp/data/routes.py` | Minimal | +| `orders/bp/actions/routes.py` | `create-order` action (called by cart during checkout) | +| `orders/bp/fragments/routes.py` | `account-nav-item` fragment (orders link) | +| `orders/templates/` | Move `_types/order/`, `_types/orders/`, checkout templates from cart | +| `orders/alembic.ini` | Standard | +| `orders/alembic/env.py` | MODELS=`["shared.models.order"]`, TABLES=`{"orders", "order_items"}` | +| `orders/alembic/versions/0001_initial.py` | Create `orders` + `order_items` tables | +| `orders/Dockerfile` | Standard, public-facing | +| `orders/entrypoint.sh` | Standard, db=`db_orders` | + +### 4.2 Move checkout services to orders + +**Move to `orders/services/`:** +- `checkout.py` — from `cart/bp/cart/services/checkout.py` (move: `create_order_from_cart`, `resolve_page_config`, `build_sumup_*`, `get_order_with_details`. Keep `find_or_create_cart_item` in cart.) +- `check_sumup_status.py` — from `cart/bp/cart/services/check_sumup_status.py` + +**`clear_cart_for_order`** stays in cart as new action: +- Add `clear-cart-for-order` to `cart/bp/actions/routes.py` +- Orders calls `call_action("cart", "clear-cart-for-order", payload={user_id, session_id, page_post_id})` + +### 4.3 `create-order` action endpoint (`orders/bp/actions/routes.py`) +Cart's `POST /checkout/` calls this: +``` +Payload: {cart_items: [{product_id, product_title, product_slug, product_image, + product_special_price, product_regular_price, product_price_currency, + quantity, market_place_container_id}], + calendar_entries, tickets, user_id, session_id, + product_total, calendar_total, ticket_total, + page_post_id, redirect_url, webhook_base_url} +Returns: {order_id, sumup_hosted_url, page_config_id, sumup_reference, description} +``` + +### 4.4 Refactor cart's checkout route +`cart/bp/cart/global_routes.py` `POST /checkout/`: +1. Load local cart data (get_cart, calendar entries, tickets, totals) +2. Serialize cart items to dicts +3. `result = await call_action("orders", "create-order", payload={...})` +4. Redirect to `result["sumup_hosted_url"]` + +Same for page-scoped checkout in `cart/bp/cart/page_routes.py`. + +### 4.5 Move webhook + return routes to orders +- `POST /checkout/webhook//` → `orders/bp/checkout/routes.py` +- `GET /checkout/return//` → `orders/bp/checkout/routes.py` +- SumUp redirect/webhook URLs must now point to orders.rose-ash.com + +### 4.6 Move order list/detail routes +- `cart/bp/order/` → `orders/bp/order/` +- `cart/bp/orders/` → `orders/bp/orders/` + +### 4.7 Move startup reconciliation +`_reconcile_pending_orders` from `cart/app.py:209-265` → `orders/app.py` + +### 4.8 Clean up cart +- Remove `cart/bp/order/`, `cart/bp/orders/` +- Remove checkout webhook/return from `cart/bp/cart/global_routes.py` +- Remove `_reconcile_pending_orders` from `cart/app.py` +- Remove order templates from `cart/templates/` +- Remove `"shared.models.order"` and `"orders", "order_items"` from `cart/alembic/env.py` + +--- + +## Phase 5: Infrastructure (applies to all new services) + +### 5.1 docker-compose.yml +Add 3 new services (relations, likes, orders) with own DATABASE_URL (db_relations, db_likes, db_orders), own REDIS_URL (Redis DB 7, 8, 9). + +Add to `x-app-env`: +```yaml +INTERNAL_URL_RELATIONS: http://relations:8000 +INTERNAL_URL_LIKES: http://likes:8000 +INTERNAL_URL_ORDERS: http://orders:8000 +APP_URL_ORDERS: https://orders.rose-ash.com +``` + +### 5.2 docker-compose.dev.yml +Add all 3 services with dev volumes (ports 8008, 8009, 8010). +Add to `x-sibling-models` for all 3 new services. + +### 5.3 deploy.sh +Add `relations likes orders` to APPS list. + +### 5.4 Caddyfile (`/root/caddy/Caddyfile`) +Add only orders (public): +``` +orders.rose-ash.com { reverse_proxy rose-ash-dev-orders-1:8000 } +``` + +### 5.5 shared/infrastructure/factory.py +Add to model import loop: `"relations.models", "likes.models", "orders.models"` + +### 5.6 shared/infrastructure/urls.py +Add `orders_url(path)` helper. + +### 5.7 All existing Dockerfiles +Add sibling model COPY lines for the 3 new services to every existing Dockerfile (blog, market, cart, events, federation, account). + +### 5.8 CLAUDE.md +Update project structure and add notes about the new services. + +--- + +## Data Migration (one-time, run before code switch) + +1. `container_relations` from `db_cart` → `db_relations` +2. `product_likes` from `db_market` + `post_likes` from `db_blog` → `db_likes.likes` +3. `page_configs` from `db_cart` → `db_blog` +4. `orders` + `order_items` from `db_cart` → `db_orders` + +Use `pg_dump`/`pg_restore` or direct SQL for migration. + +--- + +## Post-Split Cart State + +After all 4 phases, cart owns only: +- **Model**: CartItem (table in db_cart) +- **Alembic**: `cart_items` only +- **Data endpoints**: `cart-summary`, `cart-items` +- **Action endpoints**: `adopt-cart-for-user`, `clear-cart-for-order` (new) +- **Inbox handlers**: Add/Remove/Update `rose:CartItem` +- **Public routes**: cart overview, page cart, add-to-cart, quantity, delete +- **Fragments**: `cart-mini` +- **Checkout**: POST /checkout/ (creates order via `call_action("orders", "create-order")`, redirects to SumUp) + +--- + +## Verification +1. **Relations**: Blog attach/detach marketplace to page; events attach/detach calendar +2. **Likes**: Toggle product like on market page; toggle post like on blog; `?liked=true` filter +3. **PageConfig**: Blog admin page config update; cart checkout resolves page config from blog +4. **Orders**: Add to cart → checkout → SumUp redirect → webhook → order paid; order list/detail on orders.rose-ash.com +5. No remaining `call_action("cart", "attach-child|detach-child|update-page-config")` +6. No remaining `fetch_data("cart", "page-config*|get-children")` +7. Cart alembic only manages `cart_items` table diff --git a/account/app.py b/account/app.py index e328b48..1a46aae 100644 --- a/account/app.py +++ b/account/app.py @@ -44,14 +44,14 @@ async def account_context() -> dict: if ident["session_id"] is not None: cart_params["session_id"] = ident["session_id"] - cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([ + cart_mini, auth_menu, nav_tree = await fetch_fragments([ ("cart", "cart-mini", cart_params or None), ("account", "auth-menu", {"email": user.email} if user else None), ("blog", "nav-tree", {"app_name": "account", "path": request.path}), ]) - ctx["cart_mini_html"] = cart_mini_html - ctx["auth_menu_html"] = auth_menu_html - ctx["nav_tree_html"] = nav_tree_html + ctx["cart_mini"] = cart_mini + ctx["auth_menu"] = auth_menu + ctx["nav_tree"] = nav_tree return ctx diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index aeb1b12..de94bf0 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -18,6 +18,7 @@ from shared.models import UserNewsletter from shared.models.ghost_membership_entities import GhostNewsletter from shared.infrastructure.urls import login_url from shared.infrastructure.fragments import fetch_fragment, fetch_fragments +from shared.sexp.helpers import sexp_response oob = { "oob_extends": "oob_elements.html", @@ -41,7 +42,7 @@ def register(url_prefix="/"): ("cart", "account-nav-item", {}), ("artdag", "nav-item", {}), ], required=False) - return {"oob": oob, "account_nav_html": events_nav + cart_nav + artdag_nav} + return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav} @account_bp.get("/") async def account(): @@ -55,10 +56,10 @@ def register(url_prefix="/"): ctx = await get_template_context() if not is_htmx_request(): html = await render_account_page(ctx) + return await make_response(html) else: - html = await render_account_oob(ctx) - - return await make_response(html) + sexp_src = await render_account_oob(ctx) + return sexp_response(sexp_src) @account_bp.get("/newsletters/") async def newsletters(): @@ -94,10 +95,10 @@ def register(url_prefix="/"): ctx = await get_template_context() if not is_htmx_request(): html = await render_newsletters_page(ctx, newsletter_list) + return await make_response(html) else: - html = await render_newsletters_oob(ctx, newsletter_list) - - return await make_response(html) + sexp_src = await render_newsletters_oob(ctx, newsletter_list) + return sexp_response(sexp_src) @account_bp.post("/newsletter//toggle/") async def toggle_newsletter(newsletter_id: int): @@ -125,7 +126,7 @@ def register(url_prefix="/"): await g.s.flush() from sexp.sexp_components import render_newsletter_toggle - return render_newsletter_toggle(un) + return sexp_response(render_newsletter_toggle(un)) # Catch-all for fragment-provided pages — must be last @account_bp.get("//") @@ -149,9 +150,9 @@ def register(url_prefix="/"): ctx = await get_template_context() if not is_htmx_request(): html = await render_fragment_page(ctx, fragment_html) + return await make_response(html) else: - html = await render_fragment_oob(ctx, fragment_html) - - return await make_response(html) + sexp_src = await render_fragment_oob(ctx, fragment_html) + return sexp_response(sexp_src) return account_bp diff --git a/account/bp/fragments/routes.py b/account/bp/fragments/routes.py index 6574e8f..1298c54 100644 --- a/account/bp/fragments/routes.py +++ b/account/bp/fragments/routes.py @@ -1,6 +1,6 @@ """Account app fragment endpoints. -Exposes HTML fragments at ``/internal/fragments/`` for consumption +Exposes sexp fragments at ``/internal/fragments/`` for consumption by other coop apps via the fragment client. Fragments: @@ -18,18 +18,17 @@ def register(): bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") # --------------------------------------------------------------- - # Fragment handlers + # Fragment handlers — return sexp source text # --------------------------------------------------------------- async def _auth_menu(): from shared.infrastructure.urls import account_url - from shared.sexp.jinja_bridge import sexp as render_sexp + from shared.sexp.helpers import sexp_call user_email = request.args.get("email", "") - return render_sexp( - '(~auth-menu :user-email user-email :account-url account-url)', - **{"user-email": user_email or None, "account-url": account_url("")}, - ) + return sexp_call("auth-menu", + user_email=user_email or None, + account_url=account_url("")) _handlers = { "auth-menu": _auth_menu, @@ -48,8 +47,8 @@ def register(): async def get_fragment(fragment_type: str): handler = _handlers.get(fragment_type) if handler is None: - return Response("", status=200, content_type="text/html") - html = await handler() - return Response(html, status=200, content_type="text/html") + return Response("", status=200, content_type="text/sexp") + src = await handler() + return Response(src, status=200, content_type="text/sexp") return bp diff --git a/account/sexp/auth.sexpr b/account/sexp/auth.sexpr index 530fc0a..ca906e3 100644 --- a/account/sexp/auth.sexpr +++ b/account/sexp/auth.sexpr @@ -3,12 +3,12 @@ (defcomp ~account-login-error (&key error) (when error (div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" - (raw! error)))) + error))) -(defcomp ~account-login-form (&key error-html action csrf-token email) +(defcomp ~account-login-form (&key error action csrf-token email) (div :class "py-8 max-w-md mx-auto" (h1 :class "text-2xl font-bold mb-6" "Sign in") - (raw! error-html) + error (form :method "post" :action action :class "space-y-4" (input :type "hidden" :name "csrf_token" :value csrf-token) (div @@ -22,13 +22,13 @@ (defcomp ~account-device-error (&key error) (when error (div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" - (raw! error)))) + error))) -(defcomp ~account-device-form (&key error-html action csrf-token code) +(defcomp ~account-device-form (&key error action csrf-token code) (div :class "py-8 max-w-md mx-auto" (h1 :class "text-2xl font-bold mb-6" "Authorize device") (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.") - (raw! error-html) + error (form :method "post" :action action :class "space-y-4" (input :type "hidden" :name "csrf_token" :value csrf-token) (div @@ -48,11 +48,11 @@ (defcomp ~account-check-email-error (&key error) (when error (div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" - (raw! error)))) + error))) -(defcomp ~account-check-email (&key email error-html) +(defcomp ~account-check-email (&key email error) (div :class "py-8 max-w-md mx-auto text-center" (h1 :class "text-2xl font-bold mb-4" "Check your email") - (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".") + (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".") (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.") - (raw! error-html))) + error)) diff --git a/account/sexp/dashboard.sexpr b/account/sexp/dashboard.sexpr index 4f2fc7a..2305bb7 100644 --- a/account/sexp/dashboard.sexpr +++ b/account/sexp/dashboard.sexpr @@ -3,15 +3,15 @@ (defcomp ~account-error-banner (&key error) (when error (div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" - (raw! error)))) + error))) (defcomp ~account-user-email (&key email) (when email - (p :class "text-sm text-stone-500 mt-1" (raw! email)))) + (p :class "text-sm text-stone-500 mt-1" email))) (defcomp ~account-user-name (&key name) (when name - (p :class "text-sm text-stone-600" (raw! name)))) + (p :class "text-sm text-stone-600" name))) (defcomp ~account-logout-form (&key csrf-token) (form :action "/auth/logout/" :method "post" @@ -22,27 +22,27 @@ (defcomp ~account-label-item (&key name) (span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60" - (raw! name))) + name)) -(defcomp ~account-labels-section (&key items-html) - (when items-html +(defcomp ~account-labels-section (&key items) + (when items (div (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels") - (div :class "flex flex-wrap gap-2" (raw! items-html))))) + (div :class "flex flex-wrap gap-2" items)))) -(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html) +(defcomp ~account-main-panel (&key error email name logout labels) (div :class "w-full max-w-3xl mx-auto px-4 py-6" (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8" - (raw! error-html) + error (div :class "flex items-center justify-between" (div (h1 :class "text-xl font-semibold tracking-tight" "Account") - (raw! email-html) - (raw! name-html)) - (raw! logout-html)) - (raw! labels-html)))) + email + name) + logout) + labels))) ;; Header child wrapper -(defcomp ~account-header-child (&key inner-html) +(defcomp ~account-header-child (&key inner) (div :id "root-header-child" :class "flex flex-col w-full items-center" - (raw! inner-html))) + inner)) diff --git a/account/sexp/newsletters.sexpr b/account/sexp/newsletters.sexpr index 4896598..2a98f32 100644 --- a/account/sexp/newsletters.sexpr +++ b/account/sexp/newsletters.sexpr @@ -2,36 +2,36 @@ (defcomp ~account-newsletter-desc (&key description) (when description - (p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description)))) + (p :class "text-xs text-stone-500 mt-0.5 truncate" description))) (defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls) (div :id id :class "flex items-center" - (button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML" + (button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML" :class cls :role "switch" :aria-checked checked (span :class knob-cls)))) (defcomp ~account-newsletter-toggle-off (&key id url hdrs target) (div :id id :class "flex items-center" - (button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML" + (button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML" :class "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" :role "switch" :aria-checked "false" (span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1")))) -(defcomp ~account-newsletter-item (&key name desc-html toggle-html) +(defcomp ~account-newsletter-item (&key name desc toggle) (div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0" (div :class "min-w-0 flex-1" - (p :class "text-sm font-medium text-stone-800" (raw! name)) - (raw! desc-html)) - (div :class "ml-4 flex-shrink-0" (raw! toggle-html)))) + (p :class "text-sm font-medium text-stone-800" name) + desc) + (div :class "ml-4 flex-shrink-0" toggle))) -(defcomp ~account-newsletter-list (&key items-html) - (div :class "divide-y divide-stone-100" (raw! items-html))) +(defcomp ~account-newsletter-list (&key items) + (div :class "divide-y divide-stone-100" items)) (defcomp ~account-newsletter-empty () (p :class "text-sm text-stone-500" "No newsletters available.")) -(defcomp ~account-newsletters-panel (&key list-html) +(defcomp ~account-newsletters-panel (&key list) (div :class "w-full max-w-3xl mx-auto px-4 py-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") - (raw! list-html)))) + list))) diff --git a/account/sexp/sexp_components.py b/account/sexp/sexp_components.py index 422d0eb..19f2339 100644 --- a/account/sexp/sexp_components.py +++ b/account/sexp/sexp_components.py @@ -9,10 +9,10 @@ from __future__ import annotations import os from typing import Any -from shared.sexp.jinja_bridge import render, load_service_components +from shared.sexp.jinja_bridge import load_service_components from shared.sexp.helpers import ( - call_url, root_header_html, search_desktop_html, - search_mobile_html, full_page, oob_page, + call_url, sexp_call, SexpExpr, + root_header_sexp, full_page_sexp, header_child_sexp, oob_page_sexp, ) # Load account-specific .sexpr components at import time @@ -23,51 +23,53 @@ load_service_components(os.path.dirname(os.path.dirname(__file__))) # Header helpers # --------------------------------------------------------------------------- -def _auth_nav_html(ctx: dict) -> str: +def _auth_nav_sexp(ctx: dict) -> str: """Auth section desktop nav items.""" - html = render( - "nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ) - account_nav_html = ctx.get("account_nav_html", "") - if account_nav_html: - html += account_nav_html - return html + parts = [ + sexp_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_html(ctx: dict, *, oob: bool = False) -> str: +def _auth_header_sexp(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row.""" - return render( - "menu-row", + return sexp_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_html=_auth_nav_html(ctx), + nav=SexpExpr(_auth_nav_sexp(ctx)), child_id="auth-header-child", oob=oob, ) -def _auth_nav_mobile_html(ctx: dict) -> str: +def _auth_nav_mobile_sexp(ctx: dict) -> str: """Mobile nav menu for auth section.""" - html = render( - "nav-link", - href=call_url(ctx, "account_url", "/newsletters/"), - label="newsletters", - select_colours=ctx.get("select_colours", ""), - ) - account_nav_html = ctx.get("account_nav_html", "") - if account_nav_html: - html += account_nav_html - return html + parts = [ + sexp_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_html(ctx: dict) -> str: +def _account_main_panel_sexp(ctx: dict) -> str: """Account info panel with user details and logout.""" from quart import g from shared.browser.app.csrf import generate_csrf_token @@ -75,30 +77,33 @@ def _account_main_panel_html(ctx: dict) -> str: user = getattr(g, "user", None) error = ctx.get("error", "") - error_html = render("account-error-banner", error=error) if error else "" + error_sexp = sexp_call("account-error-banner", error=error) if error else "" - user_email_html = "" - user_name_html = "" + user_email_sexp = "" + user_name_sexp = "" if user: - user_email_html = render("account-user-email", email=user.email) + user_email_sexp = sexp_call("account-user-email", email=user.email) if user.name: - user_name_html = render("account-user-name", name=user.name) + user_name_sexp = sexp_call("account-user-name", name=user.name) - logout_html = render("account-logout-form", csrf_token=generate_csrf_token()) + logout_sexp = sexp_call("account-logout-form", csrf_token=generate_csrf_token()) - labels_html = "" + labels_sexp = "" if user and hasattr(user, "labels") and user.labels: - label_items = "".join( - render("account-label-item", name=label.name) + label_items = " ".join( + sexp_call("account-label-item", name=label.name) for label in user.labels ) - labels_html = render("account-labels-section", items_html=label_items) + labels_sexp = sexp_call("account-labels-section", + items=SexpExpr("(<> " + label_items + ")")) - return render( + return sexp_call( "account-main-panel", - error_html=error_html, email_html=user_email_html, - name_html=user_name_html, logout_html=logout_html, - labels_html=labels_html, + error=SexpExpr(error_sexp) if error_sexp else None, + email=SexpExpr(user_email_sexp) if user_email_sexp else None, + name=SexpExpr(user_name_sexp) if user_name_sexp else None, + logout=SexpExpr(logout_sexp), + labels=SexpExpr(labels_sexp) if labels_sexp else None, ) @@ -106,7 +111,7 @@ def _account_main_panel_html(ctx: dict) -> str: # Newsletters (GET /newsletters/) # --------------------------------------------------------------------------- -def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str: +def _newsletter_toggle_sexp(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/") @@ -118,7 +123,7 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st bg = "bg-stone-300" translate = "translate-x-1" checked = "false" - return render( + return sexp_call( "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', @@ -129,9 +134,9 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st ) -def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str: +def _newsletter_toggle_off_sexp(nid: int, toggle_url: str, csrf_token: str) -> str: """Render an unsubscribed newsletter toggle (no subscription record yet).""" - return render( + return sexp_call( "account-newsletter-toggle-off", id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', @@ -139,7 +144,7 @@ def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> s ) -def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: +def _newsletters_panel_sexp(ctx: dict, newsletter_list: list) -> str: """Newsletters management panel.""" from shared.browser.app.csrf import generate_csrf_token @@ -152,28 +157,30 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: nl = item["newsletter"] un = item.get("un") - desc_html = render( + desc_sexp = sexp_call( "account-newsletter-desc", description=nl.description ) if nl.description else "" if un: - toggle = _newsletter_toggle_html(un, account_url_fn, csrf) + toggle = _newsletter_toggle_sexp(un, account_url_fn, csrf) else: toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") - toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf) + toggle = _newsletter_toggle_off_sexp(nl.id, toggle_url, csrf) - items.append(render( + items.append(sexp_call( "account-newsletter-item", - name=nl.name, desc_html=desc_html, toggle_html=toggle, + name=nl.name, + desc=SexpExpr(desc_sexp) if desc_sexp else None, + toggle=SexpExpr(toggle), )) - list_html = render( + list_sexp = sexp_call( "account-newsletter-list", - items_html="".join(items), + items=SexpExpr("(<> " + " ".join(items) + ")"), ) else: - list_html = render("account-newsletter-empty") + list_sexp = sexp_call("account-newsletter-empty") - return render("account-newsletters-panel", list_html=list_html) + return sexp_call("account-newsletters-panel", list=SexpExpr(list_sexp)) # --------------------------------------------------------------------------- @@ -189,11 +196,12 @@ def _login_page_content(ctx: dict) -> str: email = ctx.get("email", "") action = url_for("auth.start_login") - error_html = render("account-login-error", error=error) if error else "" + error_sexp = sexp_call("account-login-error", error=error) if error else "" - return render( + return sexp_call( "account-login-form", - error_html=error_html, action=action, + error=SexpExpr(error_sexp) if error_sexp else None, + action=action, csrf_token=generate_csrf_token(), email=email, ) @@ -207,18 +215,19 @@ def _device_page_content(ctx: dict) -> str: code = ctx.get("code", "") action = url_for("auth.device_submit") - error_html = render("account-device-error", error=error) if error else "" + error_sexp = sexp_call("account-device-error", error=error) if error else "" - return render( + return sexp_call( "account-device-form", - error_html=error_html, action=action, + error=SexpExpr(error_sexp) if error_sexp else None, + action=action, csrf_token=generate_csrf_token(), code=code, ) def _device_approved_content() -> str: """Device approved success content.""" - return render("account-device-approved") + return sexp_call("account-device-approved") # --------------------------------------------------------------------------- @@ -227,28 +236,26 @@ def _device_approved_content() -> str: async def render_account_page(ctx: dict) -> str: """Full page: account dashboard.""" - main = _account_main_panel_html(ctx) + main = _account_main_panel_sexp(ctx) - hdr = root_header_html(ctx) - hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) + hdr = root_header_sexp(ctx) + hdr_child = header_child_sexp(_auth_header_sexp(ctx)) + header_rows = "(<> " + hdr + " " + hdr_child + ")" - return full_page(ctx, header_rows_html=hdr, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return full_page_sexp(ctx, header_rows=header_rows, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) async def render_account_oob(ctx: dict) -> str: """OOB response for account dashboard.""" - main = _account_main_panel_html(ctx) + main = _account_main_panel_sexp(ctx) - oobs = ( - _auth_header_html(ctx, oob=True) - + root_header_html(ctx, oob=True) - ) + oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")" - return oob_page(ctx, oobs_html=oobs, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return oob_page_sexp(oobs=oobs, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) # --------------------------------------------------------------------------- @@ -257,28 +264,26 @@ async def render_account_oob(ctx: dict) -> str: async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str: """Full page: newsletters.""" - main = _newsletters_panel_html(ctx, newsletter_list) + main = _newsletters_panel_sexp(ctx, newsletter_list) - hdr = root_header_html(ctx) - hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) + hdr = root_header_sexp(ctx) + hdr_child = header_child_sexp(_auth_header_sexp(ctx)) + header_rows = "(<> " + hdr + " " + hdr_child + ")" - return full_page(ctx, header_rows_html=hdr, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return full_page_sexp(ctx, header_rows=header_rows, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: """OOB response for newsletters.""" - main = _newsletters_panel_html(ctx, newsletter_list) + main = _newsletters_panel_sexp(ctx, newsletter_list) - oobs = ( - _auth_header_html(ctx, oob=True) - + root_header_html(ctx, oob=True) - ) + oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")" - return oob_page(ctx, oobs_html=oobs, - content_html=main, - menu_html=_auth_nav_mobile_html(ctx)) + return oob_page_sexp(oobs=oobs, + content=main, + menu=_auth_nav_mobile_sexp(ctx)) # --------------------------------------------------------------------------- @@ -287,24 +292,22 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str: """Full page: fragment-provided content.""" - hdr = root_header_html(ctx) - hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) + hdr = root_header_sexp(ctx) + hdr_child = header_child_sexp(_auth_header_sexp(ctx)) + header_rows = "(<> " + hdr + " " + hdr_child + ")" - return full_page(ctx, header_rows_html=hdr, - content_html=page_fragment_html, - menu_html=_auth_nav_mobile_html(ctx)) + return full_page_sexp(ctx, header_rows=header_rows, + content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")', + menu=_auth_nav_mobile_sexp(ctx)) async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: """OOB response for fragment pages.""" - oobs = ( - _auth_header_html(ctx, oob=True) - + root_header_html(ctx, oob=True) - ) + oobs = "(<> " + _auth_header_sexp(ctx, oob=True) + " " + root_header_sexp(ctx, oob=True) + ")" - return oob_page(ctx, oobs_html=oobs, - content_html=page_fragment_html, - menu_html=_auth_nav_mobile_html(ctx)) + return oob_page_sexp(oobs=oobs, + content=f'(~rich-text :html "{_sexp_escape(page_fragment_html)}")', + menu=_auth_nav_mobile_sexp(ctx)) # --------------------------------------------------------------------------- @@ -313,26 +316,26 @@ async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: async def render_login_page(ctx: dict) -> str: """Full page: login form.""" - hdr = root_header_html(ctx) - return full_page(ctx, header_rows_html=hdr, - content_html=_login_page_content(ctx), - meta_html='Login \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_login_page_content(ctx), + meta_html='Login \u2014 Rose Ash') async def render_device_page(ctx: dict) -> str: """Full page: device authorization form.""" - hdr = root_header_html(ctx) - return full_page(ctx, header_rows_html=hdr, - content_html=_device_page_content(ctx), - meta_html='Authorize Device \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_device_page_content(ctx), + meta_html='Authorize Device \u2014 Rose Ash') async def render_device_approved_page(ctx: dict) -> str: """Full page: device approved.""" - hdr = root_header_html(ctx) - return full_page(ctx, header_rows_html=hdr, - content_html=_device_approved_content(), - meta_html='Device Authorized \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_device_approved_content(), + meta_html='Device Authorized \u2014 Rose Ash') # --------------------------------------------------------------------------- @@ -343,13 +346,14 @@ def _check_email_content(email: str, email_error: str | None = None) -> str: """Check email confirmation content.""" from markupsafe import escape - error_html = render( + error_sexp = sexp_call( "account-check-email-error", error=str(escape(email_error)) ) if email_error else "" - return render( + return sexp_call( "account-check-email", - email=str(escape(email)), error_html=error_html, + email=str(escape(email)), + error=SexpExpr(error_sexp) if error_sexp else None, ) @@ -357,10 +361,10 @@ 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_html(ctx) - return full_page(ctx, header_rows_html=hdr, - content_html=_check_email_content(email, email_error), - meta_html='Check your email \u2014 Rose Ash') + hdr = root_header_sexp(ctx) + return full_page_sexp(ctx, header_rows=hdr, + content=_check_email_content(email, email_error), + meta_html='Check your email \u2014 Rose Ash') # --------------------------------------------------------------------------- @@ -370,7 +374,7 @@ async def render_check_email_page(ctx: dict) -> str: def render_newsletter_toggle_html(un) -> str: """Render a newsletter toggle switch for POST response.""" from shared.browser.app.csrf import generate_csrf_token - return _newsletter_toggle_html(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p, + return _newsletter_toggle_sexp(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p, generate_csrf_token()) @@ -382,4 +386,13 @@ def render_newsletter_toggle(un) -> str: if account_url_fn is None: from shared.infrastructure.urls import account_url account_url_fn = account_url - return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token()) + return _newsletter_toggle_sexp(un, account_url_fn, generate_csrf_token()) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _sexp_escape(s: str) -> str: + """Escape a string for embedding in sexp string literals.""" + return s.replace("\\", "\\\\").replace('"', '\\"') diff --git a/account/templates/_types/auth/_newsletter_toggle.html b/account/templates/_types/auth/_newsletter_toggle.html index 8bb3f69..700c402 100644 --- a/account/templates/_types/auth/_newsletter_toggle.html +++ b/account/templates/_types/auth/_newsletter_toggle.html @@ -1,9 +1,9 @@
@@ -47,11 +47,11 @@ data-confirm-confirm-text="Yes, delete" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}" - hx-trigger="confirmed" - hx-target="#menu-items-list" - hx-swap="innerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}" + sx-trigger="confirmed" + sx-target="#menu-items-list" + sx-swap="innerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"> Delete diff --git a/blog/templates/_types/menu_items/_main_panel.html b/blog/templates/_types/menu_items/_main_panel.html index bc502dd..b18df34 100644 --- a/blog/templates/_types/menu_items/_main_panel.html +++ b/blog/templates/_types/menu_items/_main_panel.html @@ -2,9 +2,9 @@
diff --git a/blog/templates/_types/menu_items/_nav_oob.html b/blog/templates/_types/menu_items/_nav_oob.html index e25189a..872b4a9 100644 --- a/blog/templates/_types/menu_items/_nav_oob.html +++ b/blog/templates/_types/menu_items/_nav_oob.html @@ -2,18 +2,18 @@ {% set _first_seg = request.path.strip('/').split('/')[0] %} diff --git a/blog/templates/_types/post/_entry_container.html b/blog/templates/_types/post/_entry_container.html index 3c3965a..685243e 100644 --- a/blog/templates/_types/post/_entry_container.html +++ b/blog/templates/_types/post/_entry_container.html @@ -1,13 +1,7 @@
+ data-scroll-arrows="entries-nav-arrow">
{% include '_types/post/_entry_items.html' with context %}
diff --git a/blog/templates/_types/post/_entry_items.html b/blog/templates/_types/post/_entry_items.html index e671368..106af53 100644 --- a/blog/templates/_types/post/_entry_items.html +++ b/blog/templates/_types/post/_entry_items.html @@ -29,10 +29,10 @@ {# Load more entries one at a time until container is full #} {% if has_more_entries %}
{% endif %} diff --git a/blog/templates/_types/post/_main_panel.html b/blog/templates/_types/post/_main_panel.html index 52a2c3a..2c62caa 100644 --- a/blog/templates/_types/post/_main_panel.html +++ b/blog/templates/_types/post/_main_panel.html @@ -12,11 +12,11 @@ {% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
Edit diff --git a/blog/templates/_types/post/admin/_associated_entries.html b/blog/templates/_types/post/admin/_associated_entries.html index d9fe853..d5538e4 100644 --- a/blog/templates/_types/post/admin/_associated_entries.html +++ b/blog/templates/_types/post/admin/_associated_entries.html @@ -15,12 +15,12 @@ data-confirm-confirm-text="Yes, remove it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}" - hx-trigger="confirmed" - hx-target="#associated-entries-list" - hx-swap="outerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' - _="on htmx:afterRequest trigger entryToggled on body" + sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}" + sx-trigger="confirmed" + sx-target="#associated-entries-list" + sx-swap="outerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))" >
{% if calendar.post.feature_image %} diff --git a/blog/templates/_types/post/admin/_calendar_view.html b/blog/templates/_types/post/admin/_calendar_view.html index 80ae33f..6e4518b 100644 --- a/blog/templates/_types/post/admin/_calendar_view.html +++ b/blog/templates/_types/post/admin/_calendar_view.html @@ -1,15 +1,15 @@
+ sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}" + sx-trigger="entryToggled from:body" + sx-swap="outerHTML"> {# Month/year navigation #}
@@ -45,12 +45,12 @@ data-confirm-confirm-text="Yes, remove it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" - hx-trigger="confirmed" - hx-target="#associated-entries-list" - hx-swap="outerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' - _="on htmx:afterRequest trigger entryToggled on body" + sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" + sx-trigger="confirmed" + sx-target="#associated-entries-list" + sx-swap="outerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))" > @@ -67,12 +67,12 @@ data-confirm-confirm-text="Yes, add it" data-confirm-cancel-text="Cancel" data-confirm-event="confirmed" - hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" - hx-trigger="confirmed" - hx-target="#associated-entries-list" - hx-swap="outerHTML" - hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' - _="on htmx:afterRequest trigger entryToggled on body" + sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" + sx-trigger="confirmed" + sx-target="#associated-entries-list" + sx-swap="outerHTML" + sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' + sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))" > {{ e.name }} diff --git a/blog/templates/_types/post/admin/_features_panel.html b/blog/templates/_types/post/admin/_features_panel.html index 19f9296..12ac7f2 100644 --- a/blog/templates/_types/post/admin/_features_panel.html +++ b/blog/templates/_types/post/admin/_features_panel.html @@ -3,11 +3,11 @@

Page Features