13 Commits

Author SHA1 Message Date
e8bc228c7f Rebrand sexp → sx across web platform (173 files)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m37s
Rename all sexp directories, files, identifiers, and references to sx.
artdag/ excluded (separate media processing DSL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:06:57 +00:00
17cebe07e7 Add sx-get to cross-domain cart and auth-menu fragment links
Cart mini and auth-menu components were rendering plain <a href>
links for cross-domain navigation. Add sx-get with OOB swap
attributes so these use the SX fetch path instead of full reloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:47:24 +00:00
82b411f25a Add cross-domain SX navigation with OOB swap
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m38s
Enable instant cross-subdomain navigation (blog → market, etc.) via
sx-get instead of full page reloads. The server prepends missing
component definitions to OOB responses so the client can render
components from other domains.

- sexp.js: send SX-Components header, add credentials for cross-origin
  fetches to .rose-ash.com/.localhost, process sexp scripts in response
  before OOB swap
- helpers.py: add components_for_request() to diff client/server
  component sets, update sexp_response() to prepend missing defs
- factory.py: add SX-Components to CORS allowed headers, add
  Access-Control-Allow-Methods
- fragments/routes.py: switch nav items from ~blog-nav-item-plain to
  ~blog-nav-item-link (sx-get enabled)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:33:12 +00:00
a643b3532d Phase 5 cleanup: remove legacy HTML components, fix nav-tree fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m43s
- Remove old raw! layout components (~app-head, ~app-layout, ~oob-response,
  ~header-row, ~menu-row, ~oob-header, ~header-child) from layout.sexp
- Convert nav-tree fragment from Jinja HTML to sexp source, fixing the
  "Unexpected character: ." parse error caused by HTML leaking into sexp
- Add _as_sexp() helper to safely coerce HTML fragments to ~rich-text
- Fix federation/sexp/search.sexpr extra closing paren
- Remove dead _html() wrappers from blog and account sexp_components
- Remove stale render import from cart sexp_components
- Add dev_watcher.py to auto-reload on .sexp/.sexpr/.js/.css changes
- Add test_parse_all.py to parse-check all 59 sexpr/sexp files
- Fix test assertions for sx- attribute prefix (was hx-)
- Add sexp.js version logging for cache debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:12:03 +00:00
22802bd36b Send all responses as sexp wire format with client-side rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m35s
- Server sends sexp source text, client (sexp.js) renders everything
- SexpExpr marker class for nested sexp composition in serialize()
- sexp_page() HTML shell with data-mount="body" for full page loads
- sexp_response() returns text/sexp for OOB/partial responses
- ~app-body layout component replaces ~app-layout (no raw!)
- ~rich-text is the only component using raw! (for CMS HTML content)
- Fragment endpoints return text/sexp, auto-wrapped in SexpExpr
- All _*_html() helpers converted to _*_sexp() returning sexp source
- Head auto-hoist: sexp.js moves meta/title/link/script[ld+json]
  from rendered body to document.head automatically
- Unknown components render warning box instead of crashing page
- Component kwargs preserve AST for lazy rendering (fixes <> in kwargs)
- Fix unterminated paren in events/sexp/tickets.sexpr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:45:07 +00:00
0d48fd22ee Add test service to CI build loop
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m55s
The test service was missing from the CI app list, so its Docker
image was never rebuilt on push (no Node.js for sexp.js parity tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:54:40 +00:00
b92e7a763e Use lazy import for quart.Response in sexp_response helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:46:58 +00:00
fec5ecdfb1 Add s-expression wire format support and test detail view
- HTMX beforeSwap hook intercepts text/sexp responses and renders
  them client-side via sexp.js before HTMX swaps the result in
- sexp_response() helper for returning text/sexp from route handlers
- Test detail page (/test/<nodeid>) with clickable test names
- HTMX navigation to detail returns sexp wire format (4x smaller
  than pre-rendered HTML), full page loads render server-side
- ~test-detail component with back link, outcome badge, and
  error traceback display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:45:28 +00:00
269bcc02be Send test dashboard component definitions to client via sexp.js
Uses client_components_tag() to emit all component definitions as
<script type="text/sexp" data-components> before </body>, making them
available for client-side rendering by sexp.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:42:42 +00:00
9f2f0dacaf Add update/hydrate methods and browser auto-init to sexp.js
Adds Sexp.update() for re-rendering data-sexp elements with new data,
Sexp.hydrate() for finding and rendering all [data-sexp] elements,
and auto-init on DOMContentLoaded + htmx:afterSwap integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:40:14 +00:00
39e013a75e Wire sexp.js into page template with auto-init and HTMX integration
- Load sexp.js in ~app-layout before body.js
- Auto-process <script type="text/sexp"> tags on DOMContentLoaded
- Re-process after htmx:afterSwap for dynamic content
- Sexp.mount(target, expr, env) for rendering into DOM elements
- Sexp.processScripts() picks up data-components and data-mount tags
- client_components_tag() Python helper serializes Component objects
  back to sexp source for client-side consumption
- 37 parity tests all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:36:49 +00:00
2df1014ee3 Add Node.js to test containers for sexp.js parity tests
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m39s
Node 20 from Debian packages — needed to run test_sexp_js.py which
verifies JS renderer output matches Python renderer output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:30:17 +00:00
e8a991834b Add sexp.js: client-side s-expression parser, evaluator, and DOM renderer
Vanilla JS (no build tools) counterpart to shared/sexp/ Python modules.
Parses s-expression text, evaluates special forms, and renders to DOM
nodes or HTML strings. Full component system with defcomp/~name.

Includes:
- Parser: tokenizer + parse/parseAll matching Python parser exactly
- Evaluator: all special forms (if, when, cond, let, and, or, lambda,
  defcomp, define, ->, set!), higher-order forms (map, filter, reduce)
- DOM renderer: createElement for HTML tags, SVG namespace support,
  component invocation, raw! for pre-rendered HTML, <> fragments
- String renderer: matches Python html.render output for SSR parity
- ~50 built-in primitives (arithmetic, string, collection, predicates)
- 35 parity tests verifying JS output matches Python output via Node.js

Also fixes Python raw! handler to properly unwrap _RawHTML objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:28:21 +00:00
331 changed files with 10012 additions and 6331 deletions

View File

@@ -0,0 +1,177 @@
# Sexp Fragment Protocol: Component Defs Between Services
## Context
Fragment endpoints return raw sexp source (e.g., `(~blog-nav-wrapper :items ...)`). The consuming service embeds this in its page sexp, which the client evaluates. But blog-specific components like `~blog-nav-wrapper` are only in blog's `_COMPONENT_ENV` — not in market's. So market's `client_components_tag()` never sends them to the client, causing "Unknown component" errors.
The fix: transfer component definitions alongside fragments. Services tell the provider what they already have; the provider sends only what's missing. The consuming service registers received defs into its `_COMPONENT_ENV` so they're included in `client_components_tag()` output for the client.
## Approach: Structured Sexp Request/Response
Replace the current GET + `X-Fragment-Request` header protocol with POST + sexp body. This aligns with the vision in `docs/sexpr-internal-protocol-first.md`.
### Request format (POST body)
```scheme
(fragment-request
:type "nav-tree"
:params (:app-name "market" :path "/")
:components (~blog-nav-wrapper ~blog-nav-item-link ~header-row-sx ...))
```
`:components` lists component names already in the consumer's `_COMPONENT_ENV`. Provider skips these.
### Response format
```scheme
(fragment-response
:defs ((defcomp ~blog-nav-wrapper (&key ...) ...) (defcomp ~blog-nav-item-link ...))
:content (~blog-nav-wrapper :items ...))
```
`:defs` contains only components the consumer doesn't have. `:content` is the fragment sexp (same as current response body).
## Changes
### 1. `shared/infrastructure/fragments.py` — Client side
**`fetch_fragment()`**: Switch from GET to POST with sexp body.
- Build request body using `sexp_call`:
```python
from shared.sexp.helpers import sexp_call, SexpExpr
from shared.sexp.jinja_bridge import _COMPONENT_ENV
comp_names = [k for k in _COMPONENT_ENV if k.startswith("~")]
body = sexp_call("fragment-request",
type=fragment_type,
params=params or {},
components=SexpExpr("(" + " ".join(comp_names) + ")"))
```
- POST to same URL, body as `text/sexp`, keep `X-Fragment-Request` header for backward compat
- Parse response: extract `:defs` and `:content` from the sexp response
- Register defs into `_COMPONENT_ENV` via `register_components()`
- Return `:content` wrapped as `SexpExpr`
**New helper `_parse_fragment_response(text)`**:
- `parse()` the response sexp
- Extract keyword args (reuse the keyword-extraction pattern from `evaluator.py`)
- Return `(defs_source, content_source)` tuple
### 2. `shared/sexp/helpers.py` — Response builder
**New `fragment_response(content, request_text)`**:
```python
def fragment_response(content: str, request_text: str) -> str:
"""Build a structured fragment response with missing component defs."""
from .parser import parse, serialize
from .types import Keyword, Component
from .jinja_bridge import _COMPONENT_ENV
# Parse request to get :components list
req = parse(request_text)
loaded = set()
# extract :components keyword value
...
# Diff against _COMPONENT_ENV, serialize missing defs
defs_parts = []
for key, val in _COMPONENT_ENV.items():
if not isinstance(val, Component):
continue
if key in loaded or f"~{val.name}" in loaded:
continue
defs_parts.append(_serialize_defcomp(val))
defs_sexp = "(" + " ".join(defs_parts) + ")" if defs_parts else "nil"
return sexp_call("fragment-response",
defs=SexpExpr(defs_sexp),
content=SexpExpr(content))
```
### 3. Fragment endpoints — All services
**Generic change in each `bp/fragments/routes.py`**: Update the route handler to accept POST, read sexp body, use `fragment_response()` for the response.
The `get_fragment` handler becomes:
```python
@bp.route("/<fragment_type>", methods=["GET", "POST"])
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/sexp")
content = await handler()
# Structured sexp protocol (POST with sexp body)
request_body = await request.get_data(as_text=True)
if request_body and request.content_type == "text/sexp":
from shared.sexp.helpers import fragment_response
body = fragment_response(content, request_body)
return Response(body, status=200, content_type="text/sexp")
# Legacy GET fallback
return Response(content, status=200, content_type="text/sexp")
```
Since all fragment endpoints follow the identical `_handlers` + `get_fragment` pattern, we can extract this into a shared helper in `fragments.py` or a new `shared/infrastructure/fragment_endpoint.py`.
### 4. Extract shared fragment endpoint helper
To avoid touching every service's fragment routes, create a shared blueprint factory:
**`shared/infrastructure/fragment_endpoint.py`**:
```python
def create_fragment_blueprint(handlers: dict) -> Blueprint:
"""Create a fragment endpoint blueprint with sexp protocol support."""
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.route("/<fragment_type>", methods=["GET", "POST"])
async def get_fragment(fragment_type: str):
handler = handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/sexp")
content = await handler()
# Sexp protocol: POST with structured request/response
if request.method == "POST" and request.content_type == "text/sexp":
request_body = await request.get_data(as_text=True)
from shared.sexp.helpers import fragment_response
body = fragment_response(content, request_body)
return Response(body, status=200, content_type="text/sexp")
return Response(content, status=200, content_type="text/sexp")
return bp
```
Then each service's `register()` just returns `create_fragment_blueprint(_handlers)`. This is a small refactor since they all duplicate the same boilerplate today.
## Files to modify
| File | Change |
|------|--------|
| `shared/infrastructure/fragments.py` | POST sexp body, parse response, register defs |
| `shared/sexp/helpers.py` | `fragment_response()` builder, `_serialize_defcomp()` |
| `shared/infrastructure/fragment_endpoint.py` | **New** — shared blueprint factory |
| `blog/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
| `market/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
| `events/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
| `cart/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
| `account/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
| `orders/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
| `federation/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
| `relations/bp/fragments/routes.py` | Use `create_fragment_blueprint` |
## Verification
1. Start blog + market services: `./dev.sh blog market`
2. Load market page — should fetch nav-tree from blog with sexp protocol
3. Check market logs: no "Unknown component" errors
4. Inspect page source: `client_components_tag()` output includes `~blog-nav-wrapper` etc.
5. Cross-domain sx-get navigation (blog → market) works without reload
6. Run sexp tests: `python3 -m pytest shared/sexp/tests/ -x -q`
7. Second page load: `:components` list in request includes blog nav components, response `:defs` is empty

View File

@@ -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/<order_id>/``orders/bp/checkout/routes.py`
- `GET /checkout/return/<order_id>/``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

View File

@@ -58,7 +58,7 @@ jobs:
fi
fi
for app in blog market cart events federation account relations likes orders; do
for app in blog market cart events federation account relations likes orders test; do
IMAGE_EXISTS=\$(docker image ls -q ${{ env.REGISTRY }}/\$app:latest 2>/dev/null)
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$app/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
echo \"Building \$app...\"

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -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

View File

@@ -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.sx.helpers import sx_response
oob = {
"oob_extends": "oob_elements.html",
@@ -41,13 +42,13 @@ 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():
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_account_page, render_account_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_account_page, render_account_oob
if not g.get("user"):
return redirect(login_url("/"))
@@ -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)
sx_src = await render_account_oob(ctx)
return sx_response(sx_src)
@account_bp.get("/newsletters/")
async def newsletters():
@@ -88,16 +89,16 @@ def register(url_prefix="/"):
"subscribed": un.subscribed if un else False,
})
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_newsletters_page, render_newsletters_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_newsletters_page, render_newsletters_oob
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)
sx_src = await render_newsletters_oob(ctx, newsletter_list)
return sx_response(sx_src)
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
@@ -124,8 +125,8 @@ def register(url_prefix="/"):
await g.s.flush()
from sexp.sexp_components import render_newsletter_toggle
return render_newsletter_toggle(un)
from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un))
# Catch-all for fragment-provided pages — must be last
@account_bp.get("/<slug>/")
@@ -143,15 +144,15 @@ def register(url_prefix="/"):
if not fragment_html:
abort(404)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_fragment_page, render_fragment_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_fragment_page, render_fragment_oob
ctx = await get_template_context()
if not is_htmx_request():
html = await render_fragment_page(ctx, fragment_html)
return await make_response(html)
else:
html = await render_fragment_oob(ctx, fragment_html)
return await make_response(html)
sx_src = await render_fragment_oob(ctx, fragment_html)
return sx_response(sx_src)
return account_bp

View File

@@ -275,8 +275,8 @@ def register(url_prefix="/auth"):
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_login_page
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
@@ -291,8 +291,8 @@ def register(url_prefix="/auth"):
is_valid, email = validate_email(email_input)
if not is_valid:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_login_page
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error="Please enter a valid email address.", email=email_input)
return await render_login_page(ctx), 400
@@ -301,8 +301,8 @@ def register(url_prefix="/auth"):
try:
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
if not allowed:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_check_email_page
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=None)
return await render_check_email_page(ctx), 200
except Exception:
@@ -324,8 +324,8 @@ def register(url_prefix="/auth"):
"Please try again in a moment."
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_check_email_page
from shared.sx.page import get_template_context
from sx.sx_components import render_check_email_page
ctx = await get_template_context(email=email, email_error=email_error)
return await render_check_email_page(ctx)
@@ -340,15 +340,15 @@ def register(url_prefix="/auth"):
user, error = await validate_magic_link(s, token)
if error:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_login_page
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error=error)
return await render_login_page(ctx), 400
user_id = user.id
except Exception:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_login_page
from shared.sx.page import get_template_context
from sx.sx_components import render_login_page
ctx = await get_template_context(error="Could not sign you in right now. Please try again.")
return await render_login_page(ctx), 502
@@ -679,8 +679,8 @@ def register(url_prefix="/auth"):
@auth_bp.get("/device/")
async def device_form():
"""Browser form where user enters the code displayed in terminal."""
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_device_page
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
code = request.args.get("code", "")
ctx = await get_template_context(code=code)
return await render_device_page(ctx)
@@ -693,8 +693,8 @@ def register(url_prefix="/auth"):
user_code = (form.get("code") or "").strip().replace("-", "").upper()
if not user_code or len(user_code) != 8:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_device_page
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", ""))
return await render_device_page(ctx), 400
@@ -703,8 +703,8 @@ def register(url_prefix="/auth"):
r = await get_auth_redis()
device_code = await r.get(f"devflow_uc:{user_code}")
if not device_code:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_device_page
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", ""))
return await render_device_page(ctx), 400
@@ -720,13 +720,13 @@ def register(url_prefix="/auth"):
# Logged in — approve immediately
ok = await _approve_device(device_code, g.user)
if not ok:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_device_page
from shared.sx.page import get_template_context
from sx.sx_components import render_device_page
ctx = await get_template_context(error="Code expired or already used.")
return await render_device_page(ctx), 400
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_device_approved_page
from shared.sx.page import get_template_context
from sx.sx_components import render_device_approved_page
ctx = await get_template_context()
return await render_device_approved_page(ctx)
@@ -734,8 +734,8 @@ def register(url_prefix="/auth"):
@auth_bp.get("/device/complete/")
async def device_complete():
"""Post-login redirect — completes approval after magic link auth."""
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_device_page, render_device_approved_page
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", "")

View File

@@ -1,6 +1,6 @@
"""Account app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
Exposes sx fragments at ``/internal/fragments/<type>`` 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 sx 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.sx.helpers import sx_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 sx_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/sx")
src = await handler()
return Response(src, status=200, content_type="text/sx")
return bp

View File

@@ -54,6 +54,7 @@ fi
RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload"
python3 -m shared.dev_watcher &
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
else
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."

View File

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

View File

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

View File

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

View File

@@ -9,13 +9,13 @@ from __future__ import annotations
import os
from typing import Any
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page,
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, sx_call, SxExpr,
root_header_sx, full_page_sx, header_child_sx, oob_page_sx,
)
# Load account-specific .sexpr components at import time
# Load account-specific .sx components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
@@ -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_sx(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 = [
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_html(ctx: dict, *, oob: bool = False) -> str:
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return render(
"menu-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_html=_auth_nav_html(ctx),
nav=SxExpr(_auth_nav_sx(ctx)),
child_id="auth-header-child", oob=oob,
)
def _auth_nav_mobile_html(ctx: dict) -> str:
def _auth_nav_mobile_sx(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 = [
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_html(ctx: dict) -> str:
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
@@ -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_sx = sx_call("account-error-banner", error=error) if error else ""
user_email_html = ""
user_name_html = ""
user_email_sx = ""
user_name_sx = ""
if user:
user_email_html = render("account-user-email", email=user.email)
user_email_sx = sx_call("account-user-email", email=user.email)
if user.name:
user_name_html = render("account-user-name", name=user.name)
user_name_sx = sx_call("account-user-name", name=user.name)
logout_html = render("account-logout-form", csrf_token=generate_csrf_token())
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
labels_html = ""
labels_sx = ""
if user and hasattr(user, "labels") and user.labels:
label_items = "".join(
render("account-label-item", name=label.name)
label_items = " ".join(
sx_call("account-label-item", name=label.name)
for label in user.labels
)
labels_html = render("account-labels-section", items_html=label_items)
labels_sx = sx_call("account-labels-section",
items=SxExpr("(<> " + label_items + ")"))
return render(
return sx_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=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,
)
@@ -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_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/")
@@ -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 sx_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_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
return render(
return sx_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_sx(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_sx = sx_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_sx(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_sx(nl.id, toggle_url, csrf)
items.append(render(
items.append(sx_call(
"account-newsletter-item",
name=nl.name, desc_html=desc_html, toggle_html=toggle,
name=nl.name,
desc=SxExpr(desc_sx) if desc_sx else None,
toggle=SxExpr(toggle),
))
list_html = render(
list_sx = sx_call(
"account-newsletter-list",
items_html="".join(items),
items=SxExpr("(<> " + " ".join(items) + ")"),
)
else:
list_html = render("account-newsletter-empty")
list_sx = sx_call("account-newsletter-empty")
return render("account-newsletters-panel", list_html=list_html)
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
# ---------------------------------------------------------------------------
@@ -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_sx = sx_call("account-login-error", error=error) if error else ""
return render(
return sx_call(
"account-login-form",
error_html=error_html, action=action,
error=SxExpr(error_sx) if error_sx 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_sx = sx_call("account-device-error", error=error) if error else ""
return render(
return sx_call(
"account-device-form",
error_html=error_html, action=action,
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 render("account-device-approved")
return sx_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_sx(ctx)
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(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_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_account_oob(ctx: dict) -> str:
"""OOB response for account dashboard."""
main = _account_main_panel_html(ctx)
main = _account_main_panel_sx(ctx)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(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_sx(ctx, newsletter_list)
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(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_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
"""OOB response for newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list)
main = _newsletters_panel_sx(ctx, newsletter_list)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(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_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(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_sx(ctx, header_rows=header_rows,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sx(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_sx(ctx, oob=True) + " " + root_header_sx(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_sx(oobs=oobs,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sx(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='<title>Login \u2014 Rose Ash</title>')
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_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
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_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
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>')
# ---------------------------------------------------------------------------
@@ -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_sx = sx_call(
"account-check-email-error", error=str(escape(email_error))
) if email_error else ""
return render(
return sx_call(
"account-check-email",
email=str(escape(email)), error_html=error_html,
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
@@ -357,22 +361,16 @@ 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='<title>Check your email \u2014 Rose Ash</title>')
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_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,
generate_csrf_token())
def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response (uses account_url)."""
@@ -382,4 +380,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_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,9 +1,9 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
sx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-target="#nl-{{ un.newsletter_id }}"
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
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"

View File

@@ -22,10 +22,10 @@
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
sx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-target="#nl-{{ item.newsletter.id }}"
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"

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -27,7 +27,7 @@ async def blog_context() -> dict:
Blog app context processor.
- cart_count/cart_total: via cart service (shared DB)
- cart_mini_html / auth_menu_html / nav_tree_html: pre-fetched fragments
- cart_mini / auth_menu / nav_tree: pre-fetched fragments
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
@@ -65,14 +65,14 @@ async def blog_context() -> dict:
auth_params = {"email": user.email} if user else {}
nav_params = {"app_name": "blog", "path": request.path}
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", auth_params or None),
("blog", "nav-tree", nav_params),
])
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

View File

@@ -14,6 +14,7 @@ from quart import (
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.config import config
from datetime import datetime
@@ -29,29 +30,30 @@ def register(url_prefix):
@bp.get("/")
@require_admin
async def home():
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_settings_page, render_settings_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_settings_page, render_settings_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_settings_page(tctx)
return await make_response(html)
else:
html = await render_settings_oob(tctx)
return await make_response(html)
sx_src = await render_settings_oob(tctx)
return sx_response(sx_src)
@bp.get("/cache/")
@require_admin
async def cache():
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_cache_page, render_cache_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_cache_page, render_cache_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_cache_page(tctx)
return await make_response(html)
else:
html = await render_cache_oob(tctx)
return await make_response(html)
sx_src = await render_cache_oob(tctx)
return sx_response(sx_src)
@bp.post("/cache_clear/")
@require_admin
@@ -59,9 +61,9 @@ def register(url_prefix):
await clear_all_cache()
if is_htmx_request():
now = datetime.now()
from shared.sexp.jinja_bridge import render as render_comp
from shared.sx.jinja_bridge import render as render_comp
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return html
return sx_response(html)
return redirect(url_for("settings.cache"))
return bp

View File

@@ -15,6 +15,7 @@ from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from shared.sx.helpers import sx_response
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
@@ -57,15 +58,15 @@ def register():
ctx = {"groups": groups, "unassigned_tags": unassigned}
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_tag_groups_page, render_tag_groups_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_tag_groups_page, render_tag_groups_oob
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
return await make_response(await render_tag_groups_page(tctx))
else:
return await make_response(await render_tag_groups_oob(tctx))
return sx_response(await render_tag_groups_oob(tctx))
@bp.post("/")
@require_admin
@@ -122,15 +123,15 @@ def register():
"assigned_tag_ids": assigned_tag_ids,
}
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_tag_group_edit_page, render_tag_group_edit_oob
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
return await make_response(await render_tag_group_edit_page(tctx))
else:
return await make_response(await render_tag_group_edit_oob(tctx))
return sx_response(await render_tag_group_edit_oob(tctx))
@bp.post("/<int:id>/")
@require_admin

View File

@@ -22,6 +22,7 @@ from .services.pages_data import pages_data
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.authz import require_admin
from shared.sx.helpers import sx_response
from shared.utils import host_url
def register(url_prefix, title):
@@ -117,7 +118,7 @@ def register(url_prefix, title):
post_slug = p_data["post"]["slug"]
# Fetch container nav from relations service
container_nav_html = await fetch_fragment("relations", "container-nav", params={
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
@@ -126,7 +127,7 @@ def register(url_prefix, title):
ctx = {
**p_data,
"base_title": get_config()["title"],
"container_nav_html": container_nav_html,
"container_nav": container_nav,
}
# Page cart badge via HTTP
@@ -142,16 +143,17 @@ def register(url_prefix, title):
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)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_home_page, render_home_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_home_page, render_home_oob
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
html = await render_home_page(tctx)
return await make_response(html)
else:
html = await render_home_oob(tctx)
return await make_response(html)
sx_src = await render_home_oob(tctx)
return sx_response(sx_src)
@blogs_bp.get("/index")
@blogs_bp.get("/index/")
@@ -179,18 +181,20 @@ def register(url_prefix, title):
"tag_groups": [],
"posts": data.get("pages", []),
}
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards
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:
html = await render_blog_page_cards(tctx)
sx_src = await render_blog_page_cards(tctx)
return sx_response(sx_src)
else:
html = await render_blog_oob(tctx)
return await make_response(html)
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
# Default: posts listing
# Drafts filter requires login; ignore if not logged in
@@ -220,33 +224,36 @@ def register(url_prefix, title):
"drafts": q.drafts if show_drafts else None,
}
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_cards
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.update(context)
if not is_htmx_request():
html = await render_blog_page(tctx)
return await make_response(html)
elif q.page > 1:
html = await render_blog_cards(tctx)
# Sx wire format — client renders blog cards
sx_src = await render_blog_cards(tctx)
return sx_response(sx_src)
else:
html = await render_blog_oob(tctx)
return await make_response(html)
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
@blogs_bp.get("/new/")
@require_admin
async def new_post():
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel()
if not is_htmx_request():
html = await render_new_post_page(tctx)
return await make_response(html)
else:
html = await render_new_post_oob(tctx)
return await make_response(html)
sx_src = await render_new_post_oob(tctx)
return sx_response(sx_src)
@blogs_bp.post("/new/")
@require_admin
@@ -267,8 +274,8 @@ def register(url_prefix, title):
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx)
@@ -276,8 +283,8 @@ def register(url_prefix, title):
ok, reason = validate_lexical(lexical_doc)
if not ok:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx)
@@ -317,17 +324,18 @@ def register(url_prefix, title):
@blogs_bp.get("/new-page/")
@require_admin
async def new_page():
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(is_page=True)
tctx["is_page"] = True
if not is_htmx_request():
html = await render_new_post_page(tctx)
return await make_response(html)
else:
html = await render_new_post_oob(tctx)
return await make_response(html)
sx_src = await render_new_post_oob(tctx)
return sx_response(sx_src)
@blogs_bp.post("/new-page/")
@require_admin
@@ -348,8 +356,8 @@ def register(url_prefix, title):
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["is_page"] = True
@@ -358,8 +366,8 @@ def register(url_prefix, title):
ok, reason = validate_lexical(lexical_doc)
if not ok:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
tctx["is_page"] = True

View File

@@ -1,6 +1,6 @@
"""Blog app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
@@ -10,13 +10,11 @@ from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.navigation import get_navigation_tree
from shared.sexp.jinja_bridge import sexp
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# Registry of fragment handlers: type -> async callable returning HTML str
_handlers: dict[str, object] = {}
@bp.before_request
@@ -28,50 +26,103 @@ 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/sx")
result = await handler()
return Response(result, status=200, content_type="text/sx")
# --- nav-tree fragment ---
# --- nav-tree fragment — returns sx source ---
async def _nav_tree_handler():
from shared.sx.helpers import sx_call, SxExpr
from shared.infrastructure.urls import (
blog_url, cart_url, market_url, events_url,
federation_url, account_url, artdag_url,
)
app_name = request.args.get("app_name", "")
path = request.args.get("path", "/")
first_seg = path.strip("/").split("/")[0]
menu_items = list(await get_navigation_tree(g.s))
# Append Art-DAG as a synthetic nav entry (not a DB MenuNode)
class _NavItem:
__slots__ = ("slug", "label", "feature_image")
def __init__(self, slug, label, feature_image=None):
self.slug = slug
self.label = label
self.feature_image = feature_image
app_slugs = {
"cart": cart_url("/"),
"market": market_url("/"),
"events": events_url("/"),
"federation": federation_url("/"),
"account": account_url("/"),
"artdag": artdag_url("/"),
}
menu_items.append(_NavItem("artdag", "art-dag"))
nav_cls = "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm"
return await render_template(
"fragments/nav_tree.html",
menu_items=menu_items,
frag_app_name=app_name,
frag_first_seg=first_seg,
)
item_sxs = []
for item in menu_items:
href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/"))
selected = "true" if (item.slug == first_seg
or item.slug == app_name) else "false"
img = sx_call("blog-nav-item-image",
src=getattr(item, "feature_image", None),
label=getattr(item, "label", item.slug))
item_sxs.append(sx_call(
"blog-nav-item-link",
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
img=SxExpr(img), label=getattr(item, "label", item.slug),
))
# artdag link
href = artdag_url("/")
selected = "true" if ("artdag" == first_seg
or "artdag" == app_name) else "false"
img = sx_call("blog-nav-item-image", src=None, label="art-dag")
item_sxs.append(sx_call(
"blog-nav-item-link",
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
img=SxExpr(img), label="art-dag",
))
if not item_sxs:
return sx_call("blog-nav-empty",
wrapper_id="menu-items-nav-wrapper")
items_frag = "(<> " + " ".join(item_sxs) + ")"
arrow_cls = "scrolling-menu-arrow-menu-items-container"
container_id = "menu-items-container"
left_hs = ("on click set #" + container_id
+ ".scrollLeft to #" + container_id + ".scrollLeft - 200")
scroll_hs = ("on scroll "
"set cls to '" + arrow_cls + "' "
"set arrows to document.getElementsByClassName(cls) "
"set show to (window.innerWidth >= 640 and "
"my.scrollWidth > my.clientWidth) "
"repeat for arrow in arrows "
"if show remove .hidden from arrow add .flex to arrow "
"else add .hidden to arrow remove .flex from arrow end "
"end")
right_hs = ("on click set #" + container_id
+ ".scrollLeft to #" + container_id + ".scrollLeft + 200")
return sx_call("blog-nav-wrapper",
arrow_cls=arrow_cls,
container_id=container_id,
left_hs=left_hs,
scroll_hs=scroll_hs,
right_hs=right_hs,
items=SxExpr(items_frag))
_handlers["nav-tree"] = _nav_tree_handler
# --- link-card fragment (s-expression rendered) ---
def _render_blog_link_card(post, link: str) -> str:
"""Render a blog link-card via the ~link-card s-expression component."""
# --- link-card fragment — returns sx source ---
def _blog_link_card_sx(post, link: str) -> str:
from shared.sx.helpers import sx_call
published = post.published_at.strftime("%d %b %Y") if post.published_at else None
return sexp(
'(~link-card :link link :title title :image image'
' :icon "fas fa-file-alt" :subtitle excerpt'
' :detail published :data-app "blog")',
link=link,
title=post.title,
image=post.feature_image,
excerpt=post.custom_excerpt or post.excerpt,
published=published,
)
return sx_call("link-card",
link=link,
title=post.title,
image=post.feature_image,
icon="fas fa-file-alt",
subtitle=post.custom_excerpt or post.excerpt,
detail=published,
data_app="blog")
async def _link_card_handler():
from shared.services.registry import services
@@ -88,7 +139,7 @@ def register():
parts.append(f"<!-- fragment:{s} -->")
post = await services.blog.get_post_by_slug(g.s, s)
if post:
parts.append(_render_blog_link_card(post, blog_url(f"/{post.slug}")))
parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}")))
return "\n".join(parts)
# Single mode
@@ -97,11 +148,10 @@ def register():
post = await services.blog.get_post_by_slug(g.s, slug)
if not post:
return ""
return _render_blog_link_card(post, blog_url(f"/{post.slug}"))
return _blog_link_card_sx(post, blog_url(f"/{post.slug}"))
_handlers["link-card"] = _link_card_handler
# Store handlers dict on blueprint so app code can register handlers
bp._fragment_handlers = _handlers
return bp

View File

@@ -13,13 +13,14 @@ from .services.menu_items import (
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
def get_menu_items_nav_oob_sync(menu_items):
"""Helper to generate OOB update for root nav menu items"""
from sexp.sexp_components import render_menu_items_nav_oob
from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items)
@bp.get("/")
@@ -29,17 +30,17 @@ def register():
menu_items = await get_all_menu_items(g.s)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_menu_items_page, render_menu_items_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_menu_items_page, render_menu_items_oob
tctx = await get_template_context()
tctx["menu_items"] = menu_items
if not is_htmx_request():
html = await render_menu_items_page(tctx)
return await make_response(html)
else:
html = await render_menu_items_oob(tctx)
return await make_response(html)
sx_src = await render_menu_items_oob(tctx)
return sx_response(sx_src)
@bp.get("/new/")
@require_admin
@@ -72,10 +73,10 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sx_response(html + nav_oob)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@@ -115,10 +116,10 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sx_response(html + nav_oob)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@@ -136,10 +137,10 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sx_response(html + nav_oob)
@bp.get("/pages/search/")
@require_admin
@@ -183,9 +184,9 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list
from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return sx_response(html + nav_oob)
return bp

View File

@@ -12,6 +12,7 @@ from quart import (
)
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.utils import host_url
def register():
@@ -51,17 +52,17 @@ def register():
"sumup_checkout_prefix": sumup_checkout_prefix,
}
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_post_admin_page, render_post_admin_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_post_admin_page, render_post_admin_oob
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
html = await render_post_admin_page(tctx)
return await make_response(html)
else:
html = await render_post_admin_oob(tctx)
return await make_response(html)
sx_src = await render_post_admin_oob(tctx)
return sx_response(sx_src)
@bp.put("/features/")
@require_admin
@@ -98,14 +99,14 @@ def register():
features = result.get("features", {})
from sexp.sexp_components import render_features_panel
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 await make_response(html)
return sx_response(html)
@bp.put("/admin/sumup/")
@require_admin
@@ -137,30 +138,30 @@ def register():
result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {})
from sexp.sexp_components import render_features_panel
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 await make_response(html)
return sx_response(html)
@bp.get("/data/")
@require_admin
async def data(slug: str):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_post_data_page, render_post_data_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_post_data_page, render_post_data_oob
data_html = await render_template("_types/post_data/_main_panel.html")
tctx = await get_template_context()
tctx["data_html"] = data_html
if not is_htmx_request():
html = await render_post_data_page(tctx)
return await make_response(html)
else:
html = await render_post_data_oob(tctx)
return await make_response(html)
sx_src = await render_post_data_oob(tctx)
return sx_response(sx_src)
@bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin
@@ -268,8 +269,8 @@ def register():
# Load entries and post for each calendar
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_post_entries_page, render_post_entries_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_post_entries_page, render_post_entries_oob
entries_html = await render_template(
"_types/post_entries/_main_panel.html",
@@ -280,10 +281,10 @@ def register():
tctx["entries_html"] = entries_html
if not is_htmx_request():
html = await render_post_entries_page(tctx)
return await make_response(html)
else:
html = await render_post_entries_oob(tctx)
return await make_response(html)
sx_src = await render_post_entries_oob(tctx)
return sx_response(sx_src)
@bp.post("/entries/<int:entry_id>/toggle/")
@require_admin
@@ -329,13 +330,13 @@ def register():
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
from sexp.sexp_components import render_associated_entries, render_nav_entries_oob
from sx.sx_components import render_associated_entries, render_nav_entries_oob
post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
return await make_response(admin_list + nav_entries_html)
return sx_response(admin_list + nav_entries_html)
@bp.get("/settings/")
@require_post_author
@@ -347,8 +348,8 @@ def register():
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1"
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_post_settings_page, render_post_settings_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_post_settings_page, render_post_settings_oob
settings_html = await render_template(
"_types/post_settings/_main_panel.html",
@@ -359,10 +360,10 @@ def register():
tctx["settings_html"] = settings_html
if not is_htmx_request():
html = await render_post_settings_page(tctx)
return await make_response(html)
else:
html = await render_post_settings_oob(tctx)
return await make_response(html)
sx_src = await render_post_settings_oob(tctx)
return sx_response(sx_src)
@bp.post("/settings/")
@require_post_author
@@ -451,8 +452,8 @@ def register():
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_post_edit_page, render_post_edit_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_post_edit_page, render_post_edit_oob
edit_html = await render_template(
"_types/post_edit/_main_panel.html",
@@ -465,10 +466,10 @@ def register():
tctx["edit_html"] = edit_html
if not is_htmx_request():
html = await render_post_edit_page(tctx)
return await make_response(html)
else:
html = await render_post_edit_oob(tctx)
return await make_response(html)
sx_src = await render_post_edit_oob(tctx)
return sx_response(sx_src)
@bp.post("/edit/")
@require_post_author
@@ -597,9 +598,8 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
@bp.post("/markets/new/")
@require_admin
@@ -624,9 +624,8 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
@bp.delete("/markets/<market_slug>/")
@require_admin
@@ -645,8 +644,7 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
return bp

View File

@@ -21,6 +21,7 @@ from shared.browser.app.redis_cacher import cache_page, clear_cache
from .admin.routes import register as register_admin
from shared.config import config
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>')
@@ -70,7 +71,7 @@ def register():
post_slug = (g.post_data.get("post") or {}).get("slug", "")
# Fetch container nav from relations service
container_nav_html = await fetch_fragment("relations", "container-nav", params={
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
@@ -79,7 +80,7 @@ def register():
ctx = {
**p_data,
"base_title": config()["title"],
"container_nav_html": container_nav_html,
"container_nav": container_nav,
}
# Page cart badge via HTTP
@@ -103,30 +104,28 @@ def register():
@bp.get("/")
@cache_page(tag="post.post_detail")
async def post_detail(slug: str):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_post_page, render_post_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_post_page, render_post_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_post_page(tctx)
return await make_response(html)
else:
html = await render_post_oob(tctx)
return await make_response(html)
sx_src = await render_post_oob(tctx)
return sx_response(sx_src)
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from shared.utils import host_url
from sexp.sexp_components import render_like_toggle_button
from sx.sx_components import render_like_toggle_button
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
# Get post_id from g.post_data
if not g.user:
html = render_like_toggle_button(slug, False, like_url)
resp = make_response(html, 403)
return resp
return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
post_id = g.post_data["post"]["id"]
user_id = g.user.id
@@ -136,8 +135,7 @@ def register():
})
liked = result["liked"]
html = render_like_toggle_button(slug, liked, like_url)
return html
return sx_response(render_like_toggle_button(slug, liked, like_url))
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import selectinload
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from models import Snippet
@@ -38,18 +39,18 @@ def register():
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_snippets_page, render_snippets_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_snippets_page, render_snippets_oob
tctx = await get_template_context()
tctx["snippets"] = snippets
tctx["is_admin"] = is_admin
if not is_htmx_request():
html = await render_snippets_page(tctx)
return await make_response(html)
else:
html = await render_snippets_oob(tctx)
return await make_response(html)
sx_src = await render_snippets_oob(tctx)
return sx_response(sx_src)
@bp.delete("/<int:snippet_id>/")
@require_login
@@ -67,9 +68,8 @@ def register():
await g.s.flush()
snippets = await _visible_snippets(g.s)
from sexp.sexp_components import render_snippets_list
html = render_snippets_list(snippets, is_admin)
return await make_response(html)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, is_admin))
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
@@ -92,8 +92,7 @@ def register():
await g.s.flush()
snippets = await _visible_snippets(g.s)
from sexp.sexp_components import render_snippets_list
html = render_snippets_list(snippets, True)
return await make_response(html)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, True))
return bp

View File

@@ -54,6 +54,7 @@ fi
RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload"
python3 -m shared.dev_watcher &
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
else
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."

View File

@@ -1,89 +0,0 @@
;; Blog card components
(defcomp ~blog-like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(button :hx-post like-url :hx-swap "outerHTML"
:hx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1"
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
(defcomp ~blog-published-status (&key timestamp)
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
(defcomp ~blog-card (&key like-html href hx-select title status-html feature-image excerpt widget-html at-bar-html)
(article :class "border-b pb-6 last:border-b-0 relative"
(raw! like-html)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(raw! status-html))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
(when widget-html (raw! widget-html))
(raw! at-bar-html)))
(defcomp ~blog-card-tile (&key href hx-select feature-image title status-html excerpt at-bar-html)
(article :class "relative"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover")))
(div :class "p-3 text-center"
(h2 :class "text-lg font-bold text-stone-900" title)
(raw! status-html)
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
(raw! at-bar-html)))
(defcomp ~blog-tag-icon-image (&key src name)
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-tag-icon-initial (&key initial)
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial))
(defcomp ~blog-tag-li (&key icon-html name)
(li (a :class "flex items-center gap-1" (raw! icon-html)
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
(defcomp ~blog-tag-bar (&key items-html)
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
(defcomp ~blog-author-with-image (&key image name)
(li :class "flex items-center gap-1"
(img :src image :alt name :class "h-5 w-5 rounded-full object-cover")
(span :class "text-stone-700" name)))
(defcomp ~blog-author-text (&key name)
(li :class "text-stone-700" name))
(defcomp ~blog-author-bar (&key items-html)
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html))))
(defcomp ~blog-at-bar (&key tag-items-html author-items-html)
(div :class "flex flex-row justify-center gap-3"
(raw! tag-items-html) (div) (raw! author-items-html)))
(defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
(i :class "fa fa-calendar mr-1") "Calendar"))
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
(i :class "fa fa-shopping-bag mr-1") "Market"))))
(defcomp ~blog-page-card (&key href hx-select title badges-html pub-html feature-image excerpt)
(article :class "border-b pb-6 last:border-b-0 relative"
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(raw! badges-html) (raw! pub-html))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))

View File

@@ -3,16 +3,16 @@
(defcomp ~blog-cache-panel (&key clear-url csrf)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
(div :class "flex flex-col md:flex-row gap-3 items-start"
(form :hx-post clear-url :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML"
(form :sx-post clear-url :sx-trigger "submit" :sx-target "#cache-status" :sx-swap "innerHTML"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
(div :id "cache-status" :class "py-2"))))
(defcomp ~blog-snippets-panel (&key list-html)
(defcomp ~blog-snippets-panel (&key list)
(div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-between items-center"
(h1 :class "text-3xl font-bold" "Snippets"))
(div :id "snippets-list" (raw! list-html))))
(div :id "snippets-list" list)))
(defcomp ~blog-snippets-empty ()
(div :class "bg-white rounded-lg shadow"
@@ -20,10 +20,10 @@
(i :class "fa fa-puzzle-piece text-4xl mb-2")
(p "No snippets yet. Create one from the blog editor."))))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options-html cls)
(select :name "visibility" :hx-patch patch-url :hx-target "#snippets-list" :hx-swap "innerHTML"
:hx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
(raw! options-html)))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
options))
(defcomp ~blog-snippet-option (&key value selected label)
(option :value value :selected selected label))
@@ -33,30 +33,30 @@
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML"
:hx-headers hx-headers
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#snippets-list" :sx-swap "innerHTML"
:sx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
(i :class "fa fa-trash") " Delete"))
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra-html)
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
(div :class "text-xs text-stone-500" owner))
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
(raw! extra-html)))
extra))
(defcomp ~blog-snippets-list (&key rows-html)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
(defcomp ~blog-snippets-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
(defcomp ~blog-menu-items-panel (&key new-url list-html)
(defcomp ~blog-menu-items-panel (&key new-url list)
(div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-end items-center"
(button :type "button" :hx-get new-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
(button :type "button" :sx-get new-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
:class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
(i :class "fa fa-plus") " Add Menu Item"))
(div :id "menu-item-form" :class "mb-6")
(div :id "menu-items-list" (raw! list-html))))
(div :id "menu-items-list" list)))
(defcomp ~blog-menu-items-empty ()
(div :class "bg-white rounded-lg shadow"
@@ -68,29 +68,29 @@
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-menu-item-row (&key img-html label slug sort-order edit-url delete-url confirm-text hx-headers)
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text hx-headers)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
(raw! img-html)
img
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" label)
(div :class "text-xs text-stone-500 truncate" slug))
(div :class "text-sm text-stone-500" (str "Order: " sort-order))
(div :class "flex gap-2 flex-shrink-0"
(button :type "button" :hx-get edit-url :hx-target "#menu-item-form" :hx-swap "innerHTML"
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
(i :class "fa fa-edit") " Edit")
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML"
:hx-headers hx-headers
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#menu-items-list" :sx-swap "innerHTML"
:sx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
(i :class "fa fa-trash") " Delete"))))
(defcomp ~blog-menu-items-list (&key rows-html)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html))))
(defcomp ~blog-menu-items-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
;; Tag groups admin
@@ -112,16 +112,16 @@
(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
:style style initial))
(defcomp ~blog-tag-group-li (&key icon-html edit-href name slug sort-order)
(defcomp ~blog-tag-group-li (&key icon edit-href name slug sort-order)
(li :class "border rounded p-3 bg-white flex items-center gap-3"
(raw! icon-html)
icon
(div :class "flex-1"
(a :href edit-href :class "font-medium text-stone-800 hover:underline" name)
(span :class "text-xs text-stone-500 ml-2" slug))
(span :class "text-xs text-stone-500" (str "order: " sort-order))))
(defcomp ~blog-tag-groups-list (&key items-html)
(ul :class "space-y-2" (raw! items-html)))
(defcomp ~blog-tag-groups-list (&key items)
(ul :class "space-y-2" items))
(defcomp ~blog-tag-groups-empty ()
(p :class "text-stone-500 text-sm" "No tag groups yet."))
@@ -129,26 +129,26 @@
(defcomp ~blog-unassigned-tag (&key name)
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
(defcomp ~blog-unassigned-tags (&key heading spans-html)
(defcomp ~blog-unassigned-tags (&key heading spans)
(div :class "border-t pt-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
(div :class "flex flex-wrap gap-2" (raw! spans-html))))
(div :class "flex flex-wrap gap-2" spans)))
(defcomp ~blog-tag-groups-main (&key form-html groups-html unassigned-html)
(defcomp ~blog-tag-groups-main (&key form groups unassigned)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
(raw! form-html) (raw! groups-html) (raw! unassigned-html)))
form groups unassigned))
;; Tag group edit
(defcomp ~blog-tag-checkbox (&key tag-id checked img-html name)
(defcomp ~blog-tag-checkbox (&key tag-id checked img name)
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
(raw! img-html) (span name)))
img (span name)))
(defcomp ~blog-tag-checkbox-image (&key src)
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags-html)
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags)
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "space-y-3"
@@ -163,7 +163,7 @@
(input :type "text" :name "feature_image" :value feature-image :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))
(div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")
(div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"
(raw! tags-html)))
tags))
(div :class "flex gap-3"
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
@@ -173,6 +173,6 @@
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
(defcomp ~blog-tag-group-edit-main (&key edit-form-html delete-form-html)
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
(raw! edit-form-html) (raw! delete-form-html)))
edit-form delete-form))

127
blog/sx/cards.sx Normal file
View File

@@ -0,0 +1,127 @@
;; Blog card components — pure data, no HTML injection
(defcomp ~blog-like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-draft-status (&key publish-requested timestamp)
(<> (div :class "flex justify-center gap-2 mt-1"
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
(defcomp ~blog-published-status (&key timestamp)
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
;; Tag components — accept data, not HTML
(defcomp ~blog-tag-icon (&key src name initial)
(if src
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial)))
(defcomp ~blog-tag-item (&key src name initial)
(li (a :class "flex items-center gap-1"
(~blog-tag-icon :src src :name name :initial initial)
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
;; At-bar — tags + authors row for detail pages
(defcomp ~blog-at-bar (&key tags authors)
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))
;; Author components
(defcomp ~blog-author-item (&key image name)
(li :class "flex items-center gap-1"
(when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover"))
(span :class "text-stone-700" name)))
;; Card — accepts pure data
(defcomp ~blog-card (&key slug href hx-select title
feature-image excerpt
status is-draft publish-requested status-timestamp
liked like-url csrf-token
has-like
tags authors widget)
(article :class "border-b pb-6 last:border-b-0 relative"
(when has-like
(~blog-like-button
:like-url like-url
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
:heart (if liked "❤️" "🤍")))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp))))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
widget
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
(defcomp ~blog-card-tile (&key href hx-select feature-image title
is-draft publish-requested status-timestamp
excerpt tags authors)
(article :class "relative"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover")))
(div :class "p-3 text-center"
(h2 :class "text-lg font-bold text-stone-900" title)
(if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp)))
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
(defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
(i :class "fa fa-calendar mr-1") "Calendar"))
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
(i :class "fa fa-shopping-bag mr-1") "Market"))))
(defcomp ~blog-page-card (&key href hx-select title has-calendar has-market pub-timestamp feature-image excerpt)
(article :class "border-b pb-6 last:border-b-0 relative"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(~blog-page-badges :has-calendar has-calendar :has-market has-market)
(when pub-timestamp (~blog-published-status :timestamp pub-timestamp)))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))

View File

@@ -1,34 +1,37 @@
;; Blog post detail components
(defcomp ~blog-detail-edit-link (&key href hx-select)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
(i :class "fa fa-pencil mr-1") " Edit"))
(defcomp ~blog-detail-draft (&key publish-requested edit-html)
(defcomp ~blog-detail-draft (&key publish-requested edit)
(div :class "flex items-center justify-center gap-2 mb-3"
(span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")
(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"))
(raw! edit-html)))
edit))
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(button :hx-post like-url :hx-swap "outerHTML"
:hx-headers hx-headers :class "cursor-pointer" heart)))
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-detail-excerpt (&key excerpt)
(div :class "w-full text-center italic text-3xl p-2" excerpt))
(defcomp ~blog-detail-chrome (&key like-html excerpt-html at-bar-html)
(<> (raw! like-html) (raw! excerpt-html) (div :class "hidden md:block" (raw! at-bar-html))))
(defcomp ~blog-detail-chrome (&key like excerpt at-bar)
(<> like
excerpt
(div :class "hidden md:block" at-bar)))
(defcomp ~blog-detail-main (&key draft-html chrome-html feature-image html-content)
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content)
(<> (article :class "relative"
(raw! draft-html) (raw! chrome-html)
draft
chrome
(when feature-image (div :class "mb-3 flex justify-center"
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
(when html-content (div :class "blog-content p-2" (raw! html-content))))
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content))))
(div :class "pb-8")))
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
@@ -48,7 +51,7 @@
(when image (meta :name "twitter:image" :content image))))
(defcomp ~blog-home-main (&key html-content)
(article :class "relative" (div :class "blog-content p-2" (raw! html-content))))
(article :class "relative" (div :class "blog-content p-2" (~rich-text :html html-content))))
(defcomp ~blog-admin-empty ()
(div :class "pb-8"))

View File

@@ -51,4 +51,4 @@
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
(defcomp ~blog-editor-scripts (&key js-src init-js)
(<> (script :src js-src) (script (raw! init-js))))
(<> (script :src js-src) (script init-js)))

View File

@@ -1,29 +1,29 @@
;; Blog filter components
(defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class icon-class) label))
(defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count)
(a :href href :hx-get href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-action-buttons-wrapper (&key inner-html)
(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner-html)))
(defcomp ~blog-action-buttons-wrapper (&key inner)
(div :class "flex flex-wrap gap-2 px-4 py-3" inner))
(defcomp ~blog-filter-any-topic (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded border " cls)
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" "Any Topic")))
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" "Any Topic")))
(defcomp ~blog-filter-group-icon-image (&key src name)
(img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"))
@@ -31,32 +31,32 @@
(defcomp ~blog-filter-group-icon-color (&key style initial)
(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial))
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon-html name count)
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls)
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true"
(raw! icon-html)
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true"
icon
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name)
(span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
(defcomp ~blog-filter-nav (&key items-html)
(defcomp ~blog-filter-nav (&key items)
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
(ul :class "divide-y flex flex-col gap-3" (raw! items-html))))
(ul :class "divide-y flex flex-col gap-3" items)))
(defcomp ~blog-filter-any-author (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded " cls)
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" "Any author")))
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" "Any author")))
(defcomp ~blog-filter-author-icon (&key src name)
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon-html name count)
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls)
:hx-get hx-get :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true"
(raw! icon-html)
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true"
icon
(span :class "text-stone-700" name)
(span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))

View File

@@ -3,9 +3,9 @@
(defcomp ~blog-header-label ()
(div))
(defcomp ~blog-container-nav (&key container-nav-html)
(defcomp ~blog-container-nav (&key container-nav)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" (raw! container-nav-html)))
:id "entries-calendars-nav-wrapper" container-nav))
(defcomp ~blog-admin-label ()
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))

View File

@@ -5,8 +5,8 @@
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:hx-swap "outerHTML" :_ hyperscript
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-8"
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
@@ -16,8 +16,8 @@
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
:hx-get next-url :hx-trigger "intersect once delay:250ms, sentinel:retry"
:hx-swap "outerHTML" :_ hyperscript
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-2"
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
@@ -25,7 +25,7 @@
(defcomp ~blog-page-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:hx-get next-url :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"))
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"))
(defcomp ~blog-no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
@@ -41,32 +41,39 @@
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg-html tile-svg-html)
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :hx-get list-href :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ "on click js localStorage.removeItem('blog_view') end" (raw! list-svg-html))
(a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ "on click js localStorage.setItem('blog_view','tile') end" (raw! tile-svg-html))))
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ "on click js localStorage.removeItem('blog_view') end" list-svg)
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ "on click js localStorage.setItem('blog_view','tile') end" tile-svg)))
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :hx-get posts-href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " posts-cls) "Posts")
(a :href pages-href :hx-get pages-href :hx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true"
(a :href pages-href :sx-get pages-href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
(defcomp ~blog-main-panel-pages (&key tabs-html cards-html)
(<> (raw! tabs-html) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards-html)) (div :class "pb-8")))
(defcomp ~blog-main-panel-pages (&key tabs cards)
(<> tabs
(div :class "max-w-full px-3 py-3 space-y-3" cards)
(div :class "pb-8")))
(defcomp ~blog-main-panel-posts (&key tabs-html toggle-html grid-cls cards-html)
(<> (raw! tabs-html) (raw! toggle-html) (div :class grid-cls (raw! cards-html)) (div :class "pb-8")))
(defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards)
(<> tabs
toggle
(div :class grid-cls cards)
(div :class "pb-8")))
(defcomp ~blog-aside (&key search-html action-buttons-html tag-groups-filter-html authors-filter-html)
(<> (raw! search-html) (raw! action-buttons-html)
(defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter)
(<> search
action-buttons
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
(raw! tag-groups-filter-html) (raw! authors-filter-html))
tag-groups-filter
authors-filter)
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))

View File

@@ -1,32 +1,32 @@
;; Blog navigation components
(defcomp ~blog-nav-empty (&key wrapper-id)
(div :id wrapper-id :hx-swap-oob "outerHTML"))
(div :id wrapper-id :sx-swap-oob "outerHTML"))
(defcomp ~blog-nav-item-image (&key src label)
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img-html label)
(div (a :href href :hx-get hx-get :hx-target "#main-panel"
:hx-swap "outerHTML" :hx-push-url "true"
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:aria-selected selected :class nav-cls
(raw! img-html) (span label))))
img (span label))))
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img-html label)
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
(div (a :href href :aria-selected selected :class nav-cls
(raw! img-html) (span label))))
img (span label))))
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items-html)
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "menu-items-nav-wrapper" :hx-swap-oob "outerHTML"
:id "menu-items-nav-wrapper" :sx-swap-oob "outerHTML"
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll left"
:_ left-hs (i :class "fa fa-chevron-left"))
(div :id container-id
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ scroll-hs
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
(div :class "flex flex-col sm:flex-row gap-1" items))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll right"
@@ -35,7 +35,7 @@
;; Nav entries
(defcomp ~blog-nav-entries-empty ()
(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"))
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
(a :href href :class nav-cls
@@ -49,9 +49,9 @@
(i :class "fa fa-calendar" :aria-hidden "true")
(div name)))
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items-html)
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" :hx-swap-oob "true"
:id "entries-calendars-nav-wrapper" :sx-swap-oob "true"
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll left"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
@@ -59,7 +59,7 @@
(div :id "associated-items-container"
:class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
:style "scroll-behavior: smooth;" :_ scroll-hs
(div :class "flex flex-col sm:flex-row gap-1" (raw! items-html)))
(div :class "flex flex-col sm:flex-row gap-1" items))
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll right"

View File

@@ -1,8 +1,8 @@
;; Blog settings panel components (features, markets, associated entries)
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
(form :hx-put features-url :hx-target "#features-panel" :hx-swap "outerHTML"
:hx-headers "{\"Content-Type\": \"application/json\"}" :hx-ext "json-enc" :class "space-y-3"
(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"
(label :class "flex items-center gap-3 cursor-pointer"
(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"
@@ -24,32 +24,32 @@
(defcomp ~blog-sumup-key-hint ()
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint-html checkout-prefix connected-html)
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint checkout-prefix connected)
(div :class "mt-4 pt-4 border-t border-stone-100"
(h4 :class "text-sm font-medium text-stone-700"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400 mt-1 mb-3"
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :hx-put sumup-url :hx-target "#features-panel" :hx-swap "outerHTML" :class "space-y-3"
(form :sx-put sumup-url :sx-target "#features-panel" :sx-swap "outerHTML" :class "space-y-3"
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
(raw! key-hint-html))
key-hint)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(button :type "submit"
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(raw! connected-html))))
connected)))
(defcomp ~blog-features-panel (&key form-html sumup-html)
(defcomp ~blog-features-panel (&key form sumup)
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
(raw! form-html) (raw! sumup-html)))
form sumup))
;; Markets panel
@@ -57,20 +57,20 @@
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
(div (span :class "font-medium" name)
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
(button :hx-delete delete-url :hx-target "#markets-panel" :hx-swap "outerHTML"
:hx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
(button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML"
:sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
(defcomp ~blog-markets-list (&key items-html)
(ul :class "space-y-2 mb-4" (raw! items-html)))
(defcomp ~blog-markets-list (&key items)
(ul :class "space-y-2 mb-4" items))
(defcomp ~blog-markets-empty ()
(p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
(defcomp ~blog-markets-panel (&key list-html create-url)
(defcomp ~blog-markets-panel (&key list create-url)
(div :id "markets-panel"
(h3 :class "text-lg font-semibold mb-3" "Markets")
(raw! list-html)
(form :hx-post create-url :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2"
list
(form :sx-post create-url :sx-target "#markets-panel" :sx-swap "outerHTML" :class "flex gap-2"
(input :type "text" :name "name" :placeholder "Market name" :required ""
:class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")
(button :type "submit"
@@ -82,32 +82,32 @@
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img-html name date-str)
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img name date-str)
(button :type "button"
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
:data-confirm "" :data-confirm-title "Remove entry?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, remove it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-post toggle-url :hx-trigger "confirmed"
:hx-target "#associated-entries-list" :hx-swap "outerHTML"
:hx-headers hx-headers
:sx-post toggle-url :sx-trigger "confirmed"
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
:sx-headers hx-headers
:_ "on htmx:afterRequest trigger entryToggled on body"
(div :class "flex items-center justify-between gap-3"
(raw! img-html)
img
(div :class "flex-1"
(div :class "font-medium text-sm" name)
(div :class "text-xs text-stone-600 mt-1" date-str))
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
(defcomp ~blog-associated-entries-content (&key items-html)
(div :class "space-y-1" (raw! items-html)))
(defcomp ~blog-associated-entries-content (&key items)
(div :class "space-y-1" items))
(defcomp ~blog-associated-entries-empty ()
(div :class "text-sm text-stone-400"
"No entries associated yet. Browse calendars below to add entries."))
(defcomp ~blog-associated-entries-panel (&key content-html)
(defcomp ~blog-associated-entries-panel (&key content)
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
(raw! content-html)))
content))

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,11 @@
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ new_href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="New Post"
>
@@ -17,11 +17,11 @@
{% set new_page_href = url_for('blog.new_page')|host %}
<a
href="{{ new_page_href }}"
hx-get="{{ new_page_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ new_page_href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
title="New Page"
>
@@ -33,11 +33,11 @@
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
<a
href="{{ drafts_off_href }}"
hx-get="{{ drafts_off_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ drafts_off_href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="Hide Drafts"
>
@@ -48,11 +48,11 @@
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
<a
href="{{ drafts_on_href }}"
hx-get="{{ drafts_on_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ drafts_on_href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
title="Show Drafts"
>

View File

@@ -14,11 +14,11 @@
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ _href }}"
sx-target="#main-panel"
sx-select ="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>

View File

@@ -2,11 +2,11 @@
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ _href }}"
sx-target="#main-panel"
sx-select ="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>

View File

@@ -11,43 +11,11 @@
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
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 sentinelmobile: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()
"
sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
sx-trigger="intersect once delay:250ms, sentinelmobile:retry"
sx-swap="outerHTML"
sx-media="(max-width: 767px)"
sx-retry="exponential:1000:30000"
role="status"
aria-live="polite"
aria-hidden="true"
@@ -58,47 +26,10 @@
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
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()
"
sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
sx-trigger="intersect once delay:250ms, sentinel:retry"
sx-swap="outerHTML"
sx-retry="exponential:1000:30000"
role="status"
aria-live="polite"
aria-hidden="true"

View File

@@ -5,21 +5,21 @@
{% set pages_href = (url_for('blog.index') ~ '?type=pages')|host %}
<a
href="{{ posts_href }}"
hx-get="{{ posts_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ posts_href }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
{{ 'bg-stone-700 text-white' if content_type != 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
>Posts</a>
<a
href="{{ pages_href }}"
hx-get="{{ pages_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ pages_href }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
{{ 'bg-stone-700 text-white' if content_type == 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
>Pages</a>
@@ -40,14 +40,14 @@
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ list_href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('blog_view') end"
onclick="localStorage.removeItem('blog_view')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
@@ -55,14 +55,14 @@
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ tile_href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('blog_view','tile') end"
onclick="localStorage.setItem('blog_view','tile')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />

View File

@@ -3,11 +3,11 @@
{% set _href = url_for('blog.post.post_detail', slug=page.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ _href }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
<header class="mb-2 text-center">

View File

@@ -6,9 +6,9 @@
<div
id="sentinel-{{ page_num }}-d"
class="h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page_num + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms"
hx-swap="outerHTML"
sx-get="{{ (current_local_href ~ {'page': page_num + 1}|qs)|host }}"
sx-trigger="intersect once delay:250ms"
sx-swap="outerHTML"
></div>
{% else %}
{% if pages %}

View File

@@ -13,11 +13,11 @@
<a
class="px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
>
Any author
</a>
@@ -32,11 +32,11 @@
<a
class="flex items-center gap-2 px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
>
{{doauthor.author(author)}}

View File

@@ -11,11 +11,11 @@
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
>
Any Topic
</a>
@@ -31,11 +31,11 @@
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
>
{% if group.feature_image %}

View File

@@ -12,11 +12,11 @@
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
>
Any Tag
</a>
@@ -31,11 +31,11 @@
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
>
{{dotag.tag(tag)}}

View File

@@ -9,11 +9,11 @@
</p>
<a
href="{{ url_for('blog.index')|host }}"
hx-get="{{ url_for('blog.index')|host }}"
hx-target="#main-panel"
hx-select="{{ hx_select }}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ url_for('blog.index')|host }}"
sx-target="#main-panel"
sx-select="{{ hx_select }}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors"
>
← Back to Blog

View File

@@ -5,11 +5,11 @@
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ new_href }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
>
<i class="fa fa-plus mr-1"></i> New Post
@@ -22,7 +22,7 @@
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
<a
href="{{ edit_href }}"
hx-boost="false"
sx-disable
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden p-4"
>
<div class="flex items-start justify-between gap-3">

View File

@@ -38,14 +38,14 @@
{# Form for submission #}
<form
{% if menu_item %}
hx-put="{{ url_for('menu_items.update_menu_item_route', item_id=menu_item.id) }}"
sx-put="{{ url_for('menu_items.update_menu_item_route', item_id=menu_item.id) }}"
{% else %}
hx-post="{{ url_for('menu_items.create_menu_item_route') }}"
sx-post="{{ url_for('menu_items.create_menu_item_route') }}"
{% endif %}
hx-target="#menu-items-list"
hx-swap="innerHTML"
hx-include="#selected-post-id"
hx-on::after-request="if(event.detail.successful) { document.getElementById('menu-item-form').innerHTML = '' }"
sx-target="#menu-items-list"
sx-swap="innerHTML"
sx-include="#selected-post-id"
sx-on:afterRequest="if(event.detail.successful) { document.getElementById('menu-item-form').innerHTML = '' }"
class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Form actions #}
@@ -74,10 +74,10 @@
<input
type="text"
placeholder="Search for a page... (or leave blank for all)"
hx-get="{{ url_for('menu_items.search_pages_route') }}"
hx-trigger="keyup changed delay:300ms, focus once"
hx-target="#page-search-results"
hx-swap="innerHTML"
sx-get="{{ url_for('menu_items.search_pages_route') }}"
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" />

View File

@@ -32,9 +32,9 @@
<div class="flex gap-2 flex-shrink-0">
<button
type="button"
hx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
hx-target="#menu-item-form"
hx-swap="innerHTML"
sx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
sx-target="#menu-item-form"
sx-swap="innerHTML"
class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded">
<i class="fa fa-edit"></i> Edit
</button>
@@ -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">
<i class="fa fa-trash"></i> Delete
</button>

View File

@@ -2,9 +2,9 @@
<div class="mb-6 flex justify-end items-center">
<button
type="button"
hx-get="{{ url_for('menu_items.new_menu_item') }}"
hx-target="#menu-item-form"
hx-swap="innerHTML"
sx-get="{{ url_for('menu_items.new_menu_item') }}"
sx-target="#menu-item-form"
sx-swap="innerHTML"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fa fa-plus"></i> Add Menu Item
</button>

View File

@@ -2,18 +2,18 @@
{% set _first_seg = request.path.strip('/').split('/')[0] %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="menu-items-nav-wrapper"
hx-swap-oob="outerHTML">
sx-swap-oob="outerHTML">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
<a
href="{{ _href }}"
{% if item.slug not in _app_slugs %}
hx-get="/{{ item.slug }}/"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="/{{ item.slug }}/"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
{% endif %}
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
class="{{styles.nav_button}}"

View File

@@ -28,10 +28,10 @@
{# Infinite scroll sentinel #}
{% if has_more %}
<div
hx-get="{{ url_for('menu_items.search_pages_route') }}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-vals='{"q": "{{ query }}", "page": {{ page + 1 }}}'
sx-get="{{ url_for('menu_items.search_pages_route') }}"
sx-trigger="intersect once"
sx-swap="outerHTML"
sx-vals='{"q": "{{ query }}", "page": {{ page + 1 }}}'
class="p-3 text-center text-sm text-stone-400">
<i class="fa fa-spinner fa-spin"></i> Loading more...
</div>

View File

@@ -1,13 +1,7 @@
<div id="associated-entries-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .entries-nav-arrow
else
add .hidden to .entries-nav-arrow
end">
data-scroll-arrows="entries-nav-arrow">
<div class="flex flex-col sm:flex-row gap-1">
{% include '_types/post/_entry_items.html' with context %}
</div>

View File

@@ -29,10 +29,10 @@
{# Load more entries one at a time until container is full #}
{% if has_more_entries %}
<div id="entries-load-sentinel-{{ current_page }}"
hx-get="{{ url_for('blog.post.get_entries', slug=post.slug, page=current_page + 1) }}"
hx-trigger="intersect once"
hx-swap="beforebegin"
_="on htmx:afterRequest trigger scroll on #associated-entries-container"
sx-get="{{ url_for('blog.post.get_entries', slug=post.slug, page=current_page + 1) }}"
sx-trigger="intersect once"
sx-swap="beforebegin"
sx-on:afterSwap="document.querySelector('#associated-entries-container').dispatchEvent(new Event('scroll'))"
class="flex-shrink-0 w-1">
</div>
{% endif %}

View File

@@ -12,11 +12,11 @@
{% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
<a
href="{{ edit_href }}"
hx-get="{{ edit_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-get="{{ edit_href }}"
sx-target="#main-panel"
sx-select="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
>
<i class="fa fa-pencil mr-1"></i> Edit

View File

@@ -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'))"
>
<div class="flex items-center justify-between gap-3">
{% if calendar.post.feature_image %}

View File

@@ -1,15 +1,15 @@
<div id="calendar-view-{{ calendar.id }}"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}"
hx-trigger="entryToggled from:body"
hx-swap="outerHTML">
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 #}
<header class="flex items-center justify-center mb-4">
<nav class="flex items-center gap-2 text-xl">
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&laquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_month_year }}&month={{ prev_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_month_year, month=prev_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&lsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_year }}&month={{ month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_year, month=month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&laquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_month_year }}&month={{ prev_month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_month_year, month=prev_month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&lsaquo;</a>
<div class="px-3 font-medium">{{ month_name }} {{ year }}</div>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_month_year }}&month={{ next_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_month_year, month=next_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&rsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&raquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_month_year }}&month={{ next_month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_month_year, month=next_month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&rsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_year }}&month={{ month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_year, month=month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&raquo;</a>
</nav>
</header>
@@ -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'))"
>
<i class="fa fa-times"></i>
</button>
@@ -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'))"
>
<span class="truncate block">{{ e.name }}</span>
</button>

View File

@@ -3,11 +3,11 @@
<h3 class="text-lg font-semibold text-stone-800">Page Features</h3>
<form
hx-put="{{ url_for('blog.post.admin.update_features', slug=post.slug)|host }}"
hx-target="#features-panel"
hx-swap="outerHTML"
hx-headers='{"Content-Type": "application/json"}'
hx-ext="json-enc"
sx-put="{{ url_for('blog.post.admin.update_features', slug=post.slug)|host }}"
sx-target="#features-panel"
sx-swap="outerHTML"
sx-headers='{"Content-Type": "application/json"}'
sx-encoding="json"
class="space-y-3"
>
<label class="flex items-center gap-3 cursor-pointer">
@@ -17,7 +17,7 @@
value="true"
{{ 'checked' if features.get('calendar') }}
class="h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
_="on change trigger submit on closest <form/>"
onchange="this.closest('form').requestSubmit()"
>
<span class="text-sm text-stone-700">
<i class="fa fa-calendar text-blue-600 mr-1"></i>
@@ -32,7 +32,7 @@
value="true"
{{ 'checked' if features.get('market') }}
class="h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"
_="on change trigger submit on closest <form/>"
onchange="this.closest('form').requestSubmit()"
>
<span class="text-sm text-stone-700">
<i class="fa fa-shopping-bag text-green-600 mr-1"></i>
@@ -53,9 +53,9 @@
</p>
<form
hx-put="{{ url_for('blog.post.admin.update_sumup', slug=post.slug)|host }}"
hx-target="#features-panel"
hx-swap="outerHTML"
sx-put="{{ url_for('blog.post.admin.update_sumup', slug=post.slug)|host }}"
sx-target="#features-panel"
sx-swap="outerHTML"
class="space-y-3"
>
<div>

View File

@@ -10,10 +10,10 @@
<span class="text-stone-400 text-sm ml-2">/{{ m.slug }}/</span>
</div>
<button
hx-delete="{{ url_for('blog.post.admin.delete_market', slug=post.slug, market_slug=m.slug) }}"
hx-target="#markets-panel"
hx-swap="outerHTML"
hx-confirm="Delete market '{{ m.name }}'?"
sx-delete="{{ url_for('blog.post.admin.delete_market', slug=post.slug, market_slug=m.slug) }}"
sx-target="#markets-panel"
sx-swap="outerHTML"
sx-confirm="Delete market '{{ m.name }}'?"
class="text-red-600 hover:text-red-800 text-sm"
>Delete</button>
</li>
@@ -24,9 +24,9 @@
{% endif %}
<form
hx-post="{{ url_for('blog.post.admin.create_market', slug=post.slug) }}"
hx-target="#markets-panel"
hx-swap="outerHTML"
sx-post="{{ url_for('blog.post.admin.create_market', slug=post.slug) }}"
sx-target="#markets-panel"
sx-swap="outerHTML"
class="flex gap-2"
>
<input

View File

@@ -3,8 +3,7 @@
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
onclick="document.getElementById('associated-items-container').scrollLeft -= 200">
<i class="fa fa-chevron-left"></i>
</button>
@@ -12,15 +11,8 @@
<div id="associated-items-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
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">
data-scroll-arrows="entries-nav-arrow"
onscroll="(function(el){var arrows=document.getElementsByClassName('entries-nav-arrow');var show=window.innerWidth>=640&&el.scrollWidth>el.clientWidth;for(var i=0;i<arrows.length;i++){if(show){arrows[i].classList.remove('hidden');arrows[i].classList.add('flex')}else{arrows[i].classList.add('hidden');arrows[i].classList.remove('flex')}}})(this)">
<div class="flex flex-col sm:flex-row gap-1">
{% for wdata in container_nav_widgets %}
{% with ctx=wdata.ctx %}
@@ -44,7 +36,6 @@
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
onclick="document.getElementById('associated-items-container').scrollLeft += 200">
<i class="fa fa-chevron-right"></i>
</button>

View File

@@ -8,14 +8,7 @@
<h3 class="text-lg font-semibold">Browse Calendars</h3>
{% for calendar in all_calendars %}
<details class="border rounded-lg bg-white"
_="on toggle
if my.open
for other in <details[open]/>
if other is not me
set other.open to false
end
end
end">
data-toggle-group="calendar-browser">
<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">
{% if calendar.post.feature_image %}
<img src="{{ calendar.post.feature_image }}"
@@ -35,9 +28,9 @@
</div>
</summary>
<div class="p-4 border-t"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id) }}"
hx-trigger="intersect once"
hx-swap="innerHTML">
sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id) }}"
sx-trigger="intersect once"
sx-swap="innerHTML">
<div class="text-sm text-stone-400">Loading calendar...</div>
</div>
</details>

View File

@@ -1,10 +1,10 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
<div class="flex flex-col md:flex-row gap-3 items-start">
<form
hx-post="{{ url_for('settings.cache_clear') }}"
hx-trigger="submit"
hx-target="#cache-status"
hx-swap="innerHTML"
sx-post="{{ url_for('settings.cache_clear') }}"
sx-trigger="submit"
sx-target="#cache-status"
sx-swap="innerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="border rounded px-4 py-2 bg-stone-800 text-white text-sm" type="submit">Clear cache</button>

View File

@@ -29,10 +29,10 @@
{% if is_admin %}
<select
name="visibility"
hx-patch="{{ url_for('snippets.patch_visibility', snippet_id=s.id) }}"
hx-target="#snippets-list"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-patch="{{ url_for('snippets.patch_visibility', snippet_id=s.id) }}"
sx-target="#snippets-list"
sx-swap="innerHTML"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="text-sm border border-stone-300 rounded px-2 py-1"
>
{% for v in ['private', 'shared', 'admin'] %}
@@ -52,11 +52,11 @@
data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('snippets.delete_snippet', snippet_id=s.id) }}"
hx-trigger="confirmed"
hx-target="#snippets-list"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-delete="{{ url_for('snippets.delete_snippet', snippet_id=s.id) }}"
sx-trigger="confirmed"
sx-target="#snippets-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 flex-shrink-0">
<i class="fa fa-trash"></i> Delete
</button>

View File

@@ -19,8 +19,7 @@
<button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
onclick="document.getElementById('{{ container_id }}').scrollLeft -= 200">
<i class="fa fa-chevron-left"></i>
</button>
@@ -28,15 +27,8 @@
<div id="{{ container_id }}"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none {{ container_class }}"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .scrolling-menu-arrow-{{ container_id }}
add .flex to .scrolling-menu-arrow-{{ container_id }}
else
add .hidden to .scrolling-menu-arrow-{{ container_id }}
remove .flex from .scrolling-menu-arrow-{{ container_id }}
end">
data-scroll-arrows="scrolling-menu-arrow-{{ container_id }}"
onscroll="(function(el){var cls='scrolling-menu-arrow-{{ container_id }}';var arrows=document.getElementsByClassName(cls);var show=window.innerWidth>=640&&el.scrollWidth>el.clientWidth;for(var i=0;i<arrows.length;i++){if(show){arrows[i].classList.remove('hidden');arrows[i].classList.add('flex')}else{arrows[i].classList.add('hidden');arrows[i].classList.remove('flex')}}})(this)">
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
{% for item in items %}
<div class="{{ item_class }}">
@@ -60,8 +52,7 @@
<button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
onclick="document.getElementById('{{ container_id }}').scrollLeft += 200">
<i class="fa fa-chevron-right"></i>
</button>
{% endif %}

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from decimal import Decimal
from pathlib import Path
@@ -50,7 +50,7 @@ async def cart_context() -> dict:
- cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html)
- nav_tree_html: fetched from blog as fragment
- nav_tree: fetched from blog as fragment
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
@@ -73,14 +73,14 @@ async def cart_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": "cart", "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
# Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or []

View File

@@ -54,7 +54,7 @@ def register(url_prefix: str) -> Blueprint:
if not cart_item:
return await make_response("Product not found", 404)
if 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
return redirect(url_for("cart_overview.overview"))
@@ -150,8 +150,8 @@ def register(url_prefix: str) -> Blueprint:
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_checkout_error_page
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error=str(e))
return await make_response(html, 400)
@@ -207,8 +207,8 @@ def register(url_prefix: str) -> Blueprint:
hosted_url = result.get("sumup_hosted_url")
if not hosted_url:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_checkout_error_page
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from quart import Blueprint, render_template, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from .services import get_cart_grouped_by_page
@@ -14,16 +15,17 @@ def register(url_prefix: str) -> Blueprint:
@bp.get("/")
async def overview():
from quart import g
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_overview_page, render_overview_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_overview_page, render_overview_oob
page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
if not is_htmx_request():
html = await render_overview_page(ctx, page_groups)
return await make_response(html)
else:
html = await render_overview_oob(ctx, page_groups)
return await make_response(html)
sx_src = await render_overview_oob(ctx, page_groups)
return sx_response(sx_src)
return bp

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from quart import Blueprint, g, redirect, make_response, url_for
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.infrastructure.actions import call_action
from .services import (
total,
@@ -40,8 +41,8 @@ def register(url_prefix: str) -> Blueprint:
ticket_total=ticket_total,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_page_cart_page, render_page_cart_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_page_cart_page, render_page_cart_oob
ctx = await get_template_context()
if not is_htmx_request():
@@ -49,12 +50,13 @@ def register(url_prefix: str) -> Blueprint:
ctx, post, cart, cal_entries, page_tickets,
ticket_groups, total, calendar_total, ticket_total,
)
return await make_response(html)
else:
html = await render_page_cart_oob(
sx_src = await render_page_cart_oob(
ctx, post, cart, cal_entries, page_tickets,
ticket_groups, total, calendar_total, ticket_total,
)
return await make_response(html)
return sx_response(sx_src)
@bp.post("/checkout/")
async def page_checkout():
@@ -109,8 +111,8 @@ def register(url_prefix: str) -> Blueprint:
hosted_url = result.get("sumup_hosted_url")
if not hosted_url:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_checkout_error_page
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.")
return await make_response(html, 500)

View File

@@ -1,6 +1,6 @@
"""Cart app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Fragments:
@@ -19,13 +19,13 @@ def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# ---------------------------------------------------------------
# Fragment handlers
# Fragment handlers — return sx source text
# ---------------------------------------------------------------
async def _cart_mini():
from shared.services.registry import services
from shared.infrastructure.urls import blog_url, cart_url
from shared.sexp.jinja_bridge import sexp as render_sexp
from shared.sx.helpers import sx_call
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
@@ -35,19 +35,19 @@ def register():
)
count = summary.count + summary.calendar_count + summary.ticket_count
oob = request.args.get("oob", "")
return render_sexp(
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url :oob oob)',
**{"cart-count": count, "blog-url": blog_url(""), "cart-url": cart_url(""), "oob": oob or None},
)
return sx_call("cart-mini",
cart_count=count,
blog_url=blog_url(""),
cart_url=cart_url(""),
oob=oob or None)
async def _account_nav_item():
from shared.infrastructure.urls import cart_url
from shared.sexp.jinja_bridge import sexp as render_sexp
from shared.sx.helpers import sx_call
return render_sexp(
'(~account-nav-item :href href :label "orders")',
href=cart_url("/orders/"),
)
return sx_call("account-nav-item",
href=cart_url("/orders/"),
label="orders")
_handlers = {
"cart-mini": _cart_mini,
@@ -67,8 +67,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/sx")
src = await handler()
return Response(src, status=200, content_type="text/sx")
return bp

View File

@@ -13,6 +13,7 @@ from shared.infrastructure.http_utils import vary as _vary, current_url_without_
from shared.infrastructure.cart_identity import current_cart_identity
from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from .filters.qs import makeqs_factory, decode
@@ -55,18 +56,18 @@ def register() -> Blueprint:
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_order_page, render_order_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_order_page, render_order_oob
ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries")
if not is_htmx_request():
html = await render_order_page(ctx, order, calendar_entries, url_for)
return await make_response(html)
else:
html = await render_order_oob(ctx, order, calendar_entries, url_for)
return await make_response(html)
sx_src = await render_order_oob(ctx, order, calendar_entries, url_for)
return sx_response(sx_src)
@bp.get("/pay/")
async def order_pay(order_id: int):
@@ -120,8 +121,8 @@ def register() -> Blueprint:
await g.s.flush()
if not hosted_url:
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_checkout_error_page
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
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)
return await make_response(html, 500)

View File

@@ -13,6 +13,7 @@ from shared.infrastructure.http_utils import vary as _vary, current_url_without_
from shared.infrastructure.cart_identity import current_cart_identity
from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from bp import register_order
from .filters.qs import makeqs_factory, decode
@@ -136,8 +137,8 @@ def register(url_prefix: str) -> Blueprint:
result = await g.s.execute(stmt)
orders = result.scalars().all()
from shared.sexp.page import get_template_context
from sexp.sexp_components import (
from shared.sx.page import get_template_context
from sx.sx_components import (
render_orders_page,
render_orders_rows,
render_orders_oob,
@@ -151,17 +152,18 @@ def register(url_prefix: str) -> Blueprint:
ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn,
)
resp = await make_response(html)
elif page > 1:
html = await render_orders_rows(
sx_src = await render_orders_rows(
ctx, orders, page, total_pages, url_for, qs_fn,
)
resp = sx_response(sx_src)
else:
html = await render_orders_oob(
sx_src = await render_orders_oob(
ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn,
)
resp = await make_response(html)
resp = sx_response(sx_src)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
return _vary(resp)

View File

@@ -8,6 +8,7 @@ from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
@@ -16,30 +17,32 @@ def register():
@bp.get("/")
@require_admin
async def admin(**kwargs):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_cart_admin_page, render_cart_admin_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_admin_page, render_cart_admin_oob
ctx = await get_template_context()
page_post = getattr(g, "page_post", None)
if not is_htmx_request():
html = await render_cart_admin_page(ctx, page_post)
return await make_response(html)
else:
html = await render_cart_admin_oob(ctx, page_post)
return await make_response(html)
sx_src = await render_cart_admin_oob(ctx, page_post)
return sx_response(sx_src)
@bp.get("/payments/")
@require_admin
async def payments(**kwargs):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_cart_payments_page, render_cart_payments_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_page, render_cart_payments_oob
ctx = await get_template_context()
page_post = getattr(g, "page_post", None)
if not is_htmx_request():
html = await render_cart_payments_page(ctx, page_post)
return await make_response(html)
else:
html = await render_cart_payments_oob(ctx, page_post)
return await make_response(html)
sx_src = await render_cart_payments_oob(ctx, page_post)
return sx_response(sx_src)
@bp.put("/payments/")
@require_admin
@@ -74,10 +77,10 @@ def register():
)
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_cart_payments_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_panel
ctx = await get_template_context()
html = render_cart_payments_panel(ctx)
return await make_response(html)
return sx_response(html)
return bp

View File

@@ -54,6 +54,7 @@ fi
RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload"
python3 -m shared.dev_watcher &
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
else
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."

View File

@@ -2,11 +2,11 @@
(defcomp ~cart-cal-entry (&key name date-str cost)
(li :class "flex items-start justify-between text-sm"
(div (div :class "font-medium" (raw! name))
(div :class "text-xs text-stone-500" (raw! date-str)))
(div :class "ml-4 font-medium" (raw! cost))))
(div (div :class "font-medium" name)
(div :class "text-xs text-stone-500" date-str))
(div :class "ml-4 font-medium" cost)))
(defcomp ~cart-cal-section (&key items-html)
(defcomp ~cart-cal-section (&key items)
(div :class "mt-6 border-t border-stone-200 pt-4"
(h2 :class "text-base font-semibold mb-2" "Calendar bookings")
(ul :class "space-y-2" (raw! items-html))))
(ul :class "space-y-2" items)))

View File

@@ -8,13 +8,13 @@
(defcomp ~cart-checkout-error-order-id (&key order-id)
(p :class "text-xs text-rose-800/80"
"Order ID: " (span :class "font-mono" (raw! order-id))))
"Order ID: " (span :class "font-mono" order-id)))
(defcomp ~cart-checkout-error-content (&key error-msg order-html back-url)
(defcomp ~cart-checkout-error-content (&key error-msg order back-url)
(div :class "max-w-full px-3 py-3 space-y-4"
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
(p :class "font-medium" "Something went wrong.")
(p (raw! error-msg))
(raw! order-html))
(p error-msg)
order)
(div (a :href back-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart"))))

View File

@@ -7,38 +7,38 @@
(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"))
(defcomp ~cart-header-child (&key inner-html)
(defcomp ~cart-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html)))
inner))
(defcomp ~cart-header-child-nested (&key outer-html inner-html)
(defcomp ~cart-header-child-nested (&key outer inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! outer-html)
outer
(div :id "cart-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html))))
inner)))
(defcomp ~cart-header-child-oob (&key inner-html)
(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-header-child-oob (&key inner)
(div :id "cart-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-auth-header-child (&key auth-html orders-html)
(defcomp ~cart-auth-header-child (&key auth orders)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! auth-html)
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
(raw! orders-html))))
orders)))
(defcomp ~cart-auth-header-child-oob (&key inner-html)
(div :id "auth-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-auth-header-child-oob (&key inner)
(div :id "auth-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-order-header-child (&key auth-html orders-html order-html)
(defcomp ~cart-order-header-child (&key auth orders order)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! auth-html)
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
(raw! orders-html)
orders
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
(raw! order-html)))))
order))))
(defcomp ~cart-orders-header-child-oob (&key inner-html)
(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-orders-header-child-oob (&key inner)
(div :id "orders-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))

View File

@@ -8,10 +8,10 @@
"No image"))
(defcomp ~cart-item-price (&key text)
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text)))
(p :class "text-sm sm:text-base font-semibold text-stone-900" text))
(defcomp ~cart-item-price-was (&key text)
(p :class "text-xs text-stone-400 line-through" (raw! text)))
(p :class "text-xs text-stone-400 line-through" text))
(defcomp ~cart-item-no-price ()
(p :class "text-xs text-stone-500" "No price"))
@@ -22,34 +22,34 @@
" This item is no longer available or price has changed"))
(defcomp ~cart-item-brand (&key brand)
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! brand)))
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" brand))
(defcomp ~cart-item-line-total (&key text)
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text)))
(p :class "text-sm sm:text-base font-semibold text-stone-900" text))
(defcomp ~cart-item (&key id img-html prod-url title brand-html deleted-html price-html qty-url csrf minus qty plus line-total-html)
(defcomp ~cart-item (&key id img prod-url title brand deleted price qty-url csrf minus qty plus line-total)
(article :id id :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
(div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (raw! img-html))
(div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (when img img))
(div :class "flex-1 min-w-0"
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
(div :class "min-w-0"
(h2 :class "text-sm sm:text-base md:text-lg font-semibold text-stone-900"
(a :href prod-url :class "hover:text-emerald-700" (raw! title)))
(raw! brand-html) (raw! deleted-html))
(div :class "text-left sm:text-right" (raw! price-html)))
(a :href prod-url :class "hover:text-emerald-700" title))
(when brand brand) (when deleted deleted))
(div :class "text-left sm:text-right" (when price price)))
(div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"
(div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"
(span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
(form :action qty-url :method "post" :sx-post qty-url :sx-swap "none"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "count" :value minus)
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))
(span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" (raw! qty))
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
(span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" qty)
(form :action qty-url :method "post" :sx-post qty-url :sx-swap "none"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "count" :value plus)
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))
(div :class "flex items-center justify-between sm:justify-end gap-3" (raw! line-total-html))))))
(div :class "flex items-center justify-between sm:justify-end gap-3" (when line-total line-total))))))
(defcomp ~cart-page-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
@@ -59,8 +59,8 @@
(i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))
(p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty")))))
(defcomp ~cart-page-panel (&key items-html cal-html tickets-html summary-html)
(defcomp ~cart-page-panel (&key items cal tickets summary)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" (raw! items-html) (raw! cal-html) (raw! tickets-html))
(raw! summary-html)))))
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
summary))))

View File

@@ -6,43 +6,43 @@
(defcomp ~cart-order-item-no-img ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image"))
(defcomp ~cart-order-item (&key prod-url img-html title product-id qty price)
(defcomp ~cart-order-item (&key prod-url img title product-id qty price)
(li (a :class "w-full py-2 flex gap-3" :href prod-url
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img-html))
(div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" img)
(div :class "flex-1 flex justify-between gap-3"
(div (p :class "font-medium" (raw! title))
(p :class "text-[11px] text-stone-500" (raw! product-id)))
(div (p :class "font-medium" title)
(p :class "text-[11px] text-stone-500" product-id))
(div :class "text-right whitespace-nowrap"
(p (raw! qty)) (p (raw! price)))))))
(p qty) (p price))))))
(defcomp ~cart-order-items-panel (&key items-html)
(defcomp ~cart-order-items-panel (&key items)
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"
(h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items-html))))
(ul :class "divide-y divide-stone-100 text-xs sm:text-sm" items)))
(defcomp ~cart-order-cal-entry (&key name pill status date-str cost)
(li :class "px-4 py-3 flex items-start justify-between text-sm"
(div (div :class "font-medium flex items-center gap-2"
(raw! name) (span :class pill (raw! status)))
(div :class "text-xs text-stone-500" (raw! date-str)))
(div :class "ml-4 font-medium" (raw! cost))))
name (span :class pill status))
(div :class "text-xs text-stone-500" date-str))
(div :class "ml-4 font-medium" cost)))
(defcomp ~cart-order-cal-section (&key items-html)
(defcomp ~cart-order-cal-section (&key items)
(section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items-html))))
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
(defcomp ~cart-order-main (&key summary-html items-html cal-html)
(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary-html) (raw! items-html) (raw! cal-html)))
(defcomp ~cart-order-main (&key summary items cal)
(div :class "max-w-full px-3 py-3 space-y-4" summary items cal))
(defcomp ~cart-order-pay-btn (&key url)
(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
(i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page"))
(defcomp ~cart-order-filter (&key info list-url recheck-url csrf pay-html)
(defcomp ~cart-order-filter (&key info list-url recheck-url csrf pay)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" (raw! info)))
(p :class "text-xs sm:text-sm text-stone-600" info))
(div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"
(a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")
@@ -50,4 +50,4 @@
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))
(raw! pay-html))))
pay)))

View File

@@ -2,11 +2,11 @@
(defcomp ~cart-order-row-desktop (&key order-id created desc total pill status detail-url)
(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" (raw! order-id)))
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created))
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc))
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total))
(td :class "px-3 py-2 align-top" (span :class pill (raw! status)))
(td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" order-id))
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" created)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" desc)
(td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" total)
(td :class "px-3 py-2 align-top" (span :class pill status))
(td :class "px-3 py-0.5 align-top text-right"
(a :href detail-url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View"))))
@@ -15,11 +15,11 @@
(td :colspan "5" :class "px-3 py-3"
(div :class "flex flex-col gap-2 text-xs"
(div :class "flex items-center justify-between gap-2"
(span :class "font-mono text-[11px] text-stone-700" (raw! order-id))
(span :class pill (raw! status)))
(div :class "text-[11px] text-stone-500 break-words" (raw! created))
(span :class "font-mono text-[11px] text-stone-700" order-id)
(span :class pill status))
(div :class "text-[11px] text-stone-500 break-words" created)
(div :class "flex items-center justify-between gap-2"
(div :class "font-medium text-stone-800" (raw! total))
(div :class "font-medium text-stone-800" total)
(a :href detail-url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View"))))))
(defcomp ~cart-orders-end ()
@@ -30,7 +30,7 @@
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"
"No orders yet.")))
(defcomp ~cart-orders-table (&key rows-html)
(defcomp ~cart-orders-table (&key rows)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
(table :class "min-w-full text-xs sm:text-sm"
@@ -42,10 +42,10 @@
(th :class "px-3 py-2 text-left font-medium" "Total")
(th :class "px-3 py-2 text-left font-medium" "Status")
(th :class "px-3 py-2 text-left font-medium")))
(tbody (raw! rows-html))))))
(tbody rows)))))
(defcomp ~cart-orders-filter (&key search-mobile-html)
(defcomp ~cart-orders-filter (&key search-mobile)
(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))
(div :class "md:hidden" (raw! search-mobile-html))))
(div :class "md:hidden" search-mobile)))

View File

@@ -2,11 +2,11 @@
(defcomp ~cart-badge (&key icon text)
(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100"
(i :class icon :aria-hidden "true") (raw! text)))
(i :class icon :aria-hidden "true") text))
(defcomp ~cart-badges-wrap (&key badges-html)
(defcomp ~cart-badges-wrap (&key badges)
(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600"
(raw! badges-html)))
badges))
(defcomp ~cart-group-card-img (&key src alt)
(img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0"))
@@ -16,29 +16,29 @@
(i :class "fa fa-store text-stone-400 text-xl" :aria-hidden "true")))
(defcomp ~cart-mp-subtitle (&key title)
(p :class "text-xs text-stone-500 truncate" (raw! title)))
(p :class "text-xs text-stone-500 truncate" title))
(defcomp ~cart-group-card (&key href img-html display-title subtitle-html badges-html total)
(defcomp ~cart-group-card (&key href img display-title subtitle badges total)
(a :href href :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
(div :class "flex items-start gap-4"
(raw! img-html)
img
(div :class "flex-1 min-w-0"
(h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" (raw! display-title))
(raw! subtitle-html) (raw! badges-html))
(h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" display-title)
subtitle badges)
(div :class "text-right flex-shrink-0"
(div :class "text-lg font-bold text-stone-900" (raw! total))
(div :class "text-lg font-bold text-stone-900" total)
(div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192")))))
(defcomp ~cart-orphan-card (&key badges-html total)
(defcomp ~cart-orphan-card (&key badges total)
(div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5"
(div :class "flex items-start gap-4"
(div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0"
(i :class "fa fa-shopping-cart text-amber-500 text-xl" :aria-hidden "true"))
(div :class "flex-1 min-w-0"
(h3 :class "text-base sm:text-lg font-semibold text-stone-900" "Other items")
(raw! badges-html))
badges)
(div :class "text-right flex-shrink-0"
(div :class "text-lg font-bold text-stone-900" (raw! total))))))
(div :class "text-lg font-bold text-stone-900" total)))))
(defcomp ~cart-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
@@ -47,6 +47,6 @@
(i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))
(p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty"))))
(defcomp ~cart-overview-panel (&key cards-html)
(defcomp ~cart-overview-panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" (raw! cards-html))))
(div :class "space-y-4" cards)))

View File

@@ -6,7 +6,7 @@
(h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :hx-put update-url :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3"
(form :sx-put update-url :sx-target "#payments-panel" :sx-swap "outerHTML" :sx-select "#payments-panel" :class "space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls))

View File

@@ -4,23 +4,23 @@
(form :method "post" :action action :class "w-full"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
(i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") (raw! label))))
(i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") label)))
(defcomp ~cart-checkout-signin (&key href)
(div :class "w-full flex"
(a :href href :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
(i :class "fa-solid fa-key") (span "sign in or register to checkout"))))
(defcomp ~cart-summary-panel (&key item-count subtotal checkout-html)
(defcomp ~cart-summary-panel (&key item-count subtotal checkout)
(aside :id "cart-summary" :class "lg:pl-2"
(div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5"
(h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary")
(dl :class "space-y-2 text-xs sm:text-sm"
(div :class "flex items-center justify-between"
(dt :class "text-stone-600" "Items") (dd :class "text-stone-900" (raw! item-count)))
(dt :class "text-stone-600" "Items") (dd :class "text-stone-900" item-count))
(div :class "flex items-center justify-between"
(dt :class "text-stone-600" "Subtotal") (dd :class "text-stone-900" (raw! subtotal))))
(dt :class "text-stone-600" "Subtotal") (dd :class "text-stone-900" subtotal)))
(div :class "flex flex-col items-center w-full"
(h1 :class "text-5xl mt-2" "This is a test - it will not take actual money")
(div "use dummy card number: 5555 5555 5555 4444"))
(div :class "mt-4 sm:mt-5" (raw! checkout-html)))))
(div :class "mt-4 sm:mt-5" checkout))))

View File

@@ -10,15 +10,17 @@ import os
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, root_header_html, post_admin_header_html,
post_header_html as _shared_post_header_html,
search_desktop_html, search_mobile_html, full_page, oob_page,
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 .sexpr components at import time
# Load cart-specific .sx components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
@@ -27,7 +29,7 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_html)."""
"""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": {
@@ -40,8 +42,8 @@ def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav_html if not already present (for post header row)."""
if ctx.get("container_nav_html"):
"""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")
@@ -58,20 +60,20 @@ async def _ensure_container_nav(ctx: dict) -> dict:
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav_html": events_nav + market_nav}
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
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_html(ctx, oob=oob)
return _shared_post_header_sx(ctx, oob=oob)
def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return render(
"menu-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",
@@ -79,27 +81,29 @@ def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
)
def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
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_html = ""
label_parts = []
if page_post and page_post.feature_image:
label_html += render("cart-page-label-img", src=page_post.feature_image)
label_html += f"<span>{title}</span>"
nav_html = render("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return render(
"menu-row",
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_html=label_html, nav_html=nav_html, oob=oob,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders)."""
return render(
"menu-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",
@@ -107,10 +111,10 @@ def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
)
def _orders_header_html(ctx: dict, list_url: str) -> str:
def _orders_header_sx(ctx: dict, list_url: str) -> str:
"""Build the orders section header row."""
return render(
"menu-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",
@@ -121,13 +125,13 @@ def _orders_header_html(ctx: dict, list_url: str) -> str:
# Cart overview
# ---------------------------------------------------------------------------
def _badge_html(icon: str, count: int, label: str) -> str:
def _badge_sx(icon: str, count: int, label: str) -> str:
"""Render a count badge."""
s = "s" if count != 1 else ""
return render("cart-badge", icon=icon, text=f"{count} {label}{s}")
return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}")
def _page_group_card_html(grp: Any, ctx: dict) -> str:
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", [])
@@ -143,14 +147,15 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str:
return ""
# Count badges
badges = ""
badge_parts = []
if product_count > 0:
badges += _badge_html("fa fa-box-open", product_count, "item")
badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item"))
if calendar_count > 0:
badges += _badge_html("fa fa-calendar", calendar_count, "booking")
badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking"))
if ticket_count > 0:
badges += _badge_html("fa fa-ticket", ticket_count, "ticket")
badges_html = render("cart-badges-wrap", badges_html=badges)
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", "")
@@ -159,58 +164,58 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str:
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image:
img = render("cart-group-card-img", src=feature_image, alt=title)
img = sx_call("cart-group-card-img", src=feature_image, alt=title)
else:
img = render("cart-group-card-placeholder")
img = sx_call("cart-group-card-placeholder")
mp_sub = ""
if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = render("cart-mp-subtitle", title=title)
mp_sub = sx_call("cart-mp-subtitle", title=title)
else:
mp_name = ""
display_title = mp_name or title
return render(
return sx_call(
"cart-group-card",
href=cart_href, img_html=img, display_title=display_title,
subtitle_html=mp_sub, badges_html=badges_html,
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 — use amber badges
badges_amber = badges.replace("bg-stone-100", "bg-amber-100")
badges_html_amber = render("cart-badges-wrap", badges_html=badges_amber)
return render(
# Orphan items
return sx_call(
"cart-orphan-card",
badges_html=badges_html_amber,
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
def _empty_cart_html() -> str:
def _empty_cart_sx() -> str:
"""Empty cart state."""
return render("cart-empty")
return sx_call("cart-empty")
def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
return _empty_cart_html()
return _empty_cart_sx()
cards = [_page_group_card_html(grp, ctx) for grp in page_groups]
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_html()
return _empty_cart_sx()
return render("cart-overview-panel", cards_html="".join(cards))
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_html(item: Any, ctx: dict) -> str:
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
@@ -225,60 +230,60 @@ def _cart_item_html(item: Any, ctx: dict) -> str:
prod_url = market_product_url(slug)
if p.image:
img = render("cart-item-img", src=p.image, alt=p.title)
img = sx_call("cart-item-img", src=p.image, alt=p.title)
else:
img = render("cart-item-no-img")
img = sx_call("cart-item-no-img")
price_html = ""
price_parts = []
if unit_price:
price_html = render("cart-item-price", text=f"{symbol}{unit_price:.2f}")
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_html += render("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}")
price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}"))
else:
price_html = render("cart-item-no-price")
price_parts.append(sx_call("cart-item-no-price"))
price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0]
deleted_html = ""
if getattr(item, "is_deleted", False):
deleted_html = render("cart-item-deleted")
deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
brand_html = ""
if getattr(p, "brand", None):
brand_html = render("cart-item-brand", brand=p.brand)
brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
line_total_html = ""
line_total_sx = None
if unit_price:
lt = unit_price * item.quantity
line_total_html = render("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
return render(
return sx_call(
"cart-item",
id=f"cart-item-{slug}", img_html=img, prod_url=prod_url, title=p.title,
brand_html=brand_html, deleted_html=deleted_html, price_html=price_html,
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_html=line_total_html,
line_total=SxExpr(line_total_sx) if line_total_sx else None,
)
def _calendar_entries_html(entries: list) -> str:
def _calendar_entries_sx(entries: list) -> str:
"""Render calendar booking entries in cart."""
if not entries:
return ""
items = ""
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 ""
items += render(
parts.append(sx_call(
"cart-cal-entry",
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
)
return render("cart-cal-section", items_html=items)
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-cal-section", items=SxExpr(items_sx))
def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str:
"""Render ticket groups in cart."""
if not ticket_groups:
return ""
@@ -287,7 +292,7 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_ticket_quantity")
items = ""
parts = []
for tg in ticket_groups:
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
@@ -304,22 +309,26 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
tt_name_html = render("cart-ticket-type-name", name=tt_name) if tt_name else ""
tt_hidden = render("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else ""
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
items += render(
parts.append(sx_call(
"cart-ticket-article",
name=name, type_name_html=tt_name_html, date_str=date_str,
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_html=tt_hidden,
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}",
)
))
return render("cart-tickets-section", items_html=items)
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-tickets-section", items=SxExpr(items_sx))
def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
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
@@ -351,38 +360,41 @@ def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
action = url_for("cart_global.checkout")
from shared.utils import route_prefix
action = route_prefix() + action
checkout_html = render(
checkout_sx = sx_call(
"cart-checkout-form",
action=action, csrf=csrf, label=f" Checkout as {user.email}",
)
else:
href = login_url(request.url)
checkout_html = render("cart-checkout-signin", href=href)
checkout_sx = sx_call("cart-checkout-signin", href=href)
return render(
return sx_call(
"cart-summary-panel",
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
checkout_html=checkout_html,
checkout=SxExpr(checkout_sx),
)
def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
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:
return render("cart-page-empty")
return sx_call("cart-page-empty")
items_html = "".join(_cart_item_html(item, ctx) for item in cart)
cal_html = _calendar_entries_html(cal_entries)
tickets_html = _ticket_groups_html(ticket_groups, ctx)
summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
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 render(
return sx_call(
"cart-page-panel",
items_html=items_html, cal_html=cal_html,
tickets_html=tickets_html, summary_html=summary_html,
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),
)
@@ -390,7 +402,7 @@ def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
# Orders list (same pattern as orders service)
# ---------------------------------------------------------------------------
def _order_row_html(order: Any, detail_url: str) -> str:
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()
@@ -403,90 +415,91 @@ def _order_row_html(order: Any, detail_url: str) -> str:
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 = render(
desktop = sx_call(
"cart-order-row-desktop",
order_id=f"#{order.id}", created=created, desc=order.description or "",
total=total, pill=pill_cls, status=status, detail_url=detail_url,
)
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
mobile = render(
mobile = sx_call(
"cart-order-row-mobile",
order_id=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, detail_url=detail_url,
)
return desktop + mobile
return "(<> " + desktop + " " + mobile + ")"
def _orders_rows_html(orders: list, page: int, total_pages: int,
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_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
_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(render(
parts.append(sx_call(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(render("cart-orders-end"))
parts.append(sx_call("cart-orders-end"))
return "".join(parts)
return "(<> " + " ".join(parts) + ")"
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
"""Main panel for orders list."""
if not orders:
return render("cart-orders-empty")
return render("cart-orders-table", rows_html=rows_html)
return sx_call("cart-orders-empty")
return sx_call("cart-orders-table", rows=SxExpr(rows_sx))
def _orders_summary_html(ctx: dict) -> str:
def _orders_summary_sx(ctx: dict) -> str:
"""Filter section for orders list."""
return render("cart-orders-filter", search_mobile_html=search_mobile_html(ctx))
return sx_call("cart-orders-filter", search_mobile=SxExpr(search_mobile_sx(ctx)))
# ---------------------------------------------------------------------------
# Single order detail
# ---------------------------------------------------------------------------
def _order_items_html(order: Any) -> str:
def _order_items_sx(order: Any) -> str:
"""Render order items list."""
if not order or not order.items:
return ""
items = ""
parts = []
for item in order.items:
prod_url = market_product_url(item.product_slug)
if item.product_image:
img = render(
img = sx_call(
"cart-order-item-img",
src=item.product_image, alt=item.product_title or "Product image",
)
else:
img = render("cart-order-item-no-img")
items += render(
img = sx_call("cart-order-item-no-img")
parts.append(sx_call(
"cart-order-item",
prod_url=prod_url, img_html=img,
prod_url=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
product_id=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}",
)
return render("cart-order-items-panel", items_html=items)
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-order-items-panel", items=SxExpr(items_sx))
def _order_summary_html(order: Any) -> str:
def _order_summary_sx(order: Any) -> str:
"""Order summary card."""
return render(
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,
@@ -495,11 +508,11 @@ def _order_summary_html(order: Any) -> str:
)
def _order_calendar_items_html(calendar_entries: list | None) -> str:
def _order_calendar_items_sx(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order."""
if not calendar_entries:
return ""
items = ""
parts = []
for e in calendar_entries:
st = e.state or ""
pill = (
@@ -512,38 +525,43 @@ def _order_calendar_items_html(calendar_entries: list | None) -> str:
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')}"
items += render(
parts.append(sx_call(
"cart-order-cal-entry",
name=e.name, pill=pill_cls, status=st.capitalize(),
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
)
return render("cart-order-cal-section", items_html=items)
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-order-cal-section", items=SxExpr(items_sx))
def _order_main_html(order: Any, calendar_entries: list | None) -> str:
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_html(order)
return render(
summary = _order_summary_sx(order)
items = _order_items_sx(order)
cal = _order_calendar_items_sx(calendar_entries)
return sx_call(
"cart-order-main",
summary_html=summary, items_html=_order_items_html(order),
cal_html=_order_calendar_items_html(calendar_entries),
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
cal=SxExpr(cal) if cal else None,
)
def _order_filter_html(order: Any, list_url: str, recheck_url: str,
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 = ""
pay_sx = None
if status != "paid":
pay = render("cart-order-pay-btn", url=pay_url)
pay_sx = sx_call("cart-order-pay-btn", url=pay_url)
return render(
return sx_call(
"cart-order-filter",
info=f"Placed {created} \u00b7 Status: {status}",
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token, pay_html=pay,
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token,
pay=SxExpr(pay_sx) if pay_sx else None,
)
@@ -553,16 +571,16 @@ def _order_filter_html(order: Any, list_url: str, recheck_url: str,
async def render_overview_page(ctx: dict, page_groups: list) -> str:
"""Full page: cart overview."""
main = _overview_main_panel_html(page_groups, ctx)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=main)
main = _overview_main_panel_sx(page_groups, ctx)
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr, content=main)
async def render_overview_oob(ctx: dict, page_groups: list) -> str:
"""OOB response for cart overview."""
main = _overview_main_panel_html(page_groups, ctx)
oobs = root_header_html(ctx, oob=True)
return oob_page(ctx, oobs_html=oobs, content_html=main)
main = _overview_main_panel_sx(page_groups, ctx)
oobs = root_header_sx(ctx, oob=True)
return oob_page_sx(oobs=oobs, content=main)
# ---------------------------------------------------------------------------
@@ -574,16 +592,17 @@ async def render_page_cart_page(ctx: dict, page_post: Any,
ticket_groups: list, total_fn: Any,
cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""Full page: page-specific cart."""
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
main = _page_cart_main_panel_sx(ctx, cart, cal_entries, tickets, ticket_groups,
total_fn, cal_total_fn, ticket_total_fn)
hdr = root_header_html(ctx)
child = _cart_header_html(ctx)
page_hdr = _page_cart_header_html(ctx, page_post)
hdr += render(
hdr = root_header_sx(ctx)
child = _cart_header_sx(ctx)
page_hdr = _page_cart_header_sx(ctx, page_post)
nested = sx_call(
"cart-header-child-nested",
outer_html=child, inner_html=page_hdr,
outer=SxExpr(child), inner=SxExpr(page_hdr),
)
return full_page(ctx, header_rows_html=hdr, content_html=main)
header_rows = "(<> " + hdr + " " + nested + ")"
return full_page_sx(ctx, header_rows=header_rows, content=main)
async def render_page_cart_oob(ctx: dict, page_post: Any,
@@ -591,14 +610,14 @@ async def render_page_cart_oob(ctx: dict, page_post: Any,
ticket_groups: list, total_fn: Any,
cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""OOB response for page cart."""
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
main = _page_cart_main_panel_sx(ctx, cart, cal_entries, tickets, ticket_groups,
total_fn, cal_total_fn, ticket_total_fn)
oobs = (
render("cart-header-child-oob", inner_html=_page_cart_header_html(ctx, page_post))
+ _cart_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, content_html=main)
child_oob = sx_call("cart-header-child-oob",
inner=SxExpr(_page_cart_header_sx(ctx, page_post)))
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
root_hdr_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
return oob_page_sx(oobs=oobs, content=main)
# ---------------------------------------------------------------------------
@@ -616,27 +635,29 @@ async def render_orders_page(ctx: dict, orders: list, page: int,
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_html(orders, rows)
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
hdr = root_header_html(ctx)
hdr += render(
hdr = root_header_sx(ctx)
auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
"cart-auth-header-child",
auth_html=_auth_header_html(ctx),
orders_html=_orders_header_html(ctx, list_url),
auth=SxExpr(auth), orders=SxExpr(orders_hdr),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
return full_page(ctx, header_rows_html=hdr,
filter_html=_orders_summary_html(ctx),
aside_html=search_desktop_html(ctx),
content_html=main)
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_html(orders, page, total_pages, url_for_fn, qs_fn)
return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
async def render_orders_oob(ctx: dict, orders: list, page: int,
@@ -650,22 +671,21 @@ async def render_orders_oob(ctx: dict, orders: list, page: int,
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_html(orders, rows)
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
oobs = (
_auth_header_html(ctx, oob=True)
+ render(
"cart-auth-header-child-oob",
inner_html=_orders_header_html(ctx, list_url),
)
+ root_header_html(ctx, oob=True)
auth_oob = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call(
"cart-auth-header-child-oob",
inner=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(ctx, oobs_html=oobs,
filter_html=_orders_summary_html(ctx),
aside_html=search_desktop_html(ctx),
content_html=main)
return oob_page_sx(oobs=oobs,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
# ---------------------------------------------------------------------------
@@ -685,23 +705,24 @@ async def render_order_page(ctx: dict, order: Any,
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_html(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_html(ctx)
order_row = render(
"menu-row",
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",
)
hdr += render(
order_child = sx_call(
"cart-order-header-child",
auth_html=_auth_header_html(ctx),
orders_html=_orders_header_html(ctx, list_url),
order_html=order_row,
auth=SxExpr(_auth_header_sx(ctx)),
orders=SxExpr(_orders_header_sx(ctx, list_url)),
order=SxExpr(order_row),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
return full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx: dict, order: Any,
@@ -717,78 +738,78 @@ async def render_order_oob(ctx: dict, order: Any,
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_html(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = render(
"menu-row",
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,
)
oobs = (
render("cart-orders-header-child-oob", inner_html=order_row_oob)
+ root_header_html(ctx, oob=True)
)
orders_child_oob = sx_call("cart-orders-header-child-oob",
inner=SxExpr(order_row_oob))
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main)
return oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error
# ---------------------------------------------------------------------------
def _checkout_error_filter_html() -> str:
return render("cart-checkout-error-filter")
def _checkout_error_filter_sx() -> str:
return sx_call("cart-checkout-error-filter")
def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
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_html = ""
order_sx = None
if order:
order_html = render("cart-checkout-error-order-id", order_id=f"#{order.id}")
order_sx = sx_call("cart-checkout-error-order-id", order_id=f"#{order.id}")
back_url = cart_url("/")
return render(
return sx_call(
"cart-checkout-error-content",
error_msg=err_msg, order_html=order_html, back_url=back_url,
error_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_html(ctx)
filt = _checkout_error_filter_html()
content = _checkout_error_content_html(error, order)
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content)
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_html(ctx: dict, page_post: Any, *, oob: bool = False,
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."""
"""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_html(ctx, slug, oob=oob, selected=selected)
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
def _cart_admin_main_panel_html(ctx: dict) -> str:
"""Admin overview panel links to sub-admin pages."""
def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Admin overview panel -- links to sub-admin pages."""
from quart import url_for
payments_href = url_for("page_admin.payments")
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"></i> Payments</span>'
f'<a href="{payments_href}" class="text-sm underline">configure</a>'
'</div>'
'</div>'
'(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_html(ctx: dict) -> str:
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")
@@ -802,11 +823,11 @@ def _cart_payments_main_panel_html(ctx: dict) -> str:
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 render("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)
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)
# ---------------------------------------------------------------------------
@@ -815,18 +836,19 @@ def _cart_payments_main_panel_html(ctx: dict) -> str:
async def render_cart_admin_page(ctx: dict, page_post: Any) -> str:
"""Full page: cart page admin overview."""
content = _cart_admin_main_panel_html(ctx)
root_hdr = root_header_html(ctx)
post_hdr = await _post_header_html(ctx, page_post)
admin_hdr = _cart_page_admin_header_html(ctx, page_post)
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
content = _cart_admin_main_panel_sx(ctx)
root_hdr = root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = _cart_page_admin_header_sx(ctx, page_post)
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
"""OOB response: cart page admin overview."""
content = _cart_admin_main_panel_html(ctx)
oobs = _cart_page_admin_header_html(ctx, page_post, oob=True)
return oob_page(ctx, oobs_html=oobs, content_html=content)
content = _cart_admin_main_panel_sx(ctx)
oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True)
return oob_page_sx(oobs=oobs, content=content)
# ---------------------------------------------------------------------------
@@ -835,20 +857,21 @@ async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
async def render_cart_payments_page(ctx: dict, page_post: Any) -> str:
"""Full page: payments config."""
content = _cart_payments_main_panel_html(ctx)
root_hdr = root_header_html(ctx)
post_hdr = await _post_header_html(ctx, page_post)
admin_hdr = _cart_page_admin_header_html(ctx, page_post, selected="payments")
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
content = _cart_payments_main_panel_sx(ctx)
root_hdr = root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected="payments")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str:
"""OOB response: payments config."""
content = _cart_payments_main_panel_html(ctx)
oobs = _cart_page_admin_header_html(ctx, page_post, oob=True, selected="payments")
return oob_page(ctx, oobs_html=oobs, content_html=content)
content = _cart_payments_main_panel_sx(ctx)
oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True, selected="payments")
return oob_page_sx(oobs=oobs, content=content)
def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _cart_payments_main_panel_html(ctx)
return _cart_payments_main_panel_sx(ctx)

View File

@@ -1,42 +1,42 @@
;; Cart ticket components
(defcomp ~cart-ticket-type-name (&key name)
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! name)))
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" name))
(defcomp ~cart-ticket-type-hidden (&key value)
(input :type "hidden" :name "ticket_type_id" :value value))
(defcomp ~cart-ticket-article (&key name type-name-html date-str price qty-url csrf entry-id type-hidden-html minus qty plus line-total)
(defcomp ~cart-ticket-article (&key name type-name date-str price qty-url csrf entry-id type-hidden minus qty plus line-total)
(article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4"
(div :class "flex-1 min-w-0"
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
(div :class "min-w-0"
(h3 :class "text-sm sm:text-base font-semibold text-stone-900" (raw! name))
(raw! type-name-html)
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! date-str)))
(h3 :class "text-sm sm:text-base font-semibold text-stone-900" name)
type-name
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" date-str))
(div :class "text-left sm:text-right"
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! price))))
(p :class "text-sm sm:text-base font-semibold text-stone-900" price)))
(div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"
(div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"
(span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
(form :action qty-url :method "post" :sx-post qty-url :sx-swap "none"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(raw! type-hidden-html)
type-hidden
(input :type "hidden" :name "count" :value minus)
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))
(span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" (raw! qty))
(form :action qty-url :method "post" :hx-post qty-url :hx-swap "none"
(span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" qty)
(form :action qty-url :method "post" :sx-post qty-url :sx-swap "none"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id)
(raw! type-hidden-html)
type-hidden
(input :type "hidden" :name "count" :value plus)
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))
(div :class "flex items-center justify-between sm:justify-end gap-3"
(p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! line-total)))))))
(p :class "text-sm sm:text-base font-semibold text-stone-900" line-total))))))
(defcomp ~cart-tickets-section (&key items-html)
(defcomp ~cart-tickets-section (&key items)
(div :class "mt-6 border-t border-stone-200 pt-4"
(h2 :class "text-base font-semibold mb-2"
(i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets")
(div :class "space-y-3" (raw! items-html))))
(div :class "space-y-3" items)))

View File

@@ -1,5 +1,5 @@
{% macro show_cart(oob=False) %}
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
<div id="cart" {% if oob %} sx-swap-oob="{{oob}}" {% endif%}>
{# Empty cart #}
{% if not cart and not calendar_cart_entries and not ticket_cart_entries %}
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
@@ -103,8 +103,8 @@
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
sx-post="{{ qty_url }}"
sx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
@@ -127,8 +127,8 @@
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
sx-post="{{ qty_url }}"
sx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
@@ -169,7 +169,7 @@
{% macro summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries, oob=False) %}
<aside id="cart-summary" class="lg:pl-2" {% if oob %} hx-swap-oob="{{oob}}" {% endif %}>
<aside id="cart-summary" class="lg:pl-2" {% if oob %} sx-swap-oob="{{oob}}" {% endif %}>
<div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">
<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
Order summary

View File

@@ -1,5 +1,5 @@
{% macro mini(oob=False, count=None) %}
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %} >
<div id="cart-mini" {% if oob %}sx-swap-oob="{{oob}}"{% endif %} >
{# cart_count is set by the context processor in all apps.
Cart app computes it from g.cart + calendar_cart_entries;
other apps get it from the cart internal API.

View File

@@ -102,43 +102,10 @@
{% if page < total_pages|int %}
<tr
id="orders-sentinel-{{ page }}"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on sentinel:retry
remove .hidden from .js-loading in me
add .hidden to .js-neterr in me
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
trigger htmx:consume on me
call htmx.trigger(me, 'intersect')
end
def backoff()
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
set myMs to Number(me.dataset.retryMs)
if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end
js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs)
end
on htmx:beforeRequest
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
end
on htmx:afterSwap
set me.dataset.retryMs to 1000
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
sx-trigger="intersect once delay:250ms"
sx-swap="outerHTML"
sx-retry="exponential:1000:30000"
role="status"
aria-live="polite"
aria-hidden="true"

View File

@@ -3,15 +3,15 @@
| selectattr('product.slug', 'equalto', slug)
| sum(attribute='quantity') %}
<div id="cart-{{ slug }}" {% if oob=='true' %} hx-swap-oob="{{oob}}" {% endif %}>
<div id="cart-{{ slug }}" {% if oob=='true' %} sx-swap-oob="{{oob}}" {% endif %}>
{% if not quantity %}
<form
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
sx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
sx-target="#cart-mini"
sx-swap="outerHTML"
class="rounded flex items-center"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -40,9 +40,9 @@
<form
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
sx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
sx-target="#cart-mini"
sx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
@@ -82,9 +82,9 @@
<form
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
sx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
sx-target="#cart-mini"
sx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
@@ -113,7 +113,7 @@
<article
id="cart-item-{{p.slug}}"
{% if oob %}
hx-swap-oob="{{oob}}"
sx-swap-oob="{{oob}}"
{% endif %}
class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
>
@@ -143,10 +143,10 @@
<a
href="{{ href }}"
hx_get="{{href}}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
sx-target="#main-panel"
sx-select ="{{hx_select_search}}"
sx-swap="outerHTML"
sx-push-url="true"
class="hover:text-emerald-700"
>
{{ p.title }}
@@ -192,8 +192,8 @@
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
sx-post="{{ qty_url }}"
sx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
@@ -214,8 +214,8 @@
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
sx-post="{{ qty_url }}"
sx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input

View File

@@ -45,7 +45,7 @@ services:
- ./blog/alembic.ini:/app/blog/alembic.ini:ro
- ./blog/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py
- ./blog/sexp:/app/sexp
- ./blog/sx:/app/sx
- ./blog/bp:/app/bp
- ./blog/services:/app/services
- ./blog/templates:/app/templates
@@ -83,7 +83,7 @@ services:
- ./market/alembic.ini:/app/market/alembic.ini:ro
- ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py
- ./market/sexp:/app/sexp
- ./market/sx:/app/sx
- ./market/bp:/app/bp
- ./market/services:/app/services
- ./market/templates:/app/templates
@@ -120,7 +120,7 @@ services:
- ./cart/alembic.ini:/app/cart/alembic.ini:ro
- ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py
- ./cart/sexp:/app/sexp
- ./cart/sx:/app/sx
- ./cart/bp:/app/bp
- ./cart/services:/app/services
- ./cart/templates:/app/templates
@@ -157,7 +157,7 @@ services:
- ./events/alembic.ini:/app/events/alembic.ini:ro
- ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py
- ./events/sexp:/app/sexp
- ./events/sx:/app/sx
- ./events/bp:/app/bp
- ./events/services:/app/services
- ./events/templates:/app/templates
@@ -194,7 +194,7 @@ services:
- ./federation/alembic.ini:/app/federation/alembic.ini:ro
- ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py
- ./federation/sexp:/app/sexp
- ./federation/sx:/app/sx
- ./federation/bp:/app/bp
- ./federation/services:/app/services
- ./federation/templates:/app/templates
@@ -231,7 +231,7 @@ services:
- ./account/alembic.ini:/app/account/alembic.ini:ro
- ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py
- ./account/sexp:/app/sexp
- ./account/sx:/app/sx
- ./account/bp:/app/bp
- ./account/services:/app/services
- ./account/templates:/app/templates
@@ -330,7 +330,7 @@ services:
- ./orders/alembic.ini:/app/orders/alembic.ini:ro
- ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py
- ./orders/sexp:/app/sexp
- ./orders/sx:/app/sx
- ./orders/bp:/app/bp
- ./orders/services:/app/services
- ./orders/templates:/app/templates
@@ -361,7 +361,7 @@ services:
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
- ./shared:/app/shared
- ./test/app.py:/app/app.py
- ./test/sexp:/app/sexp
- ./test/sx:/app/sx
- ./test/bp:/app/bp
- ./test/services:/app/services
- ./test/runner.py:/app/runner.py

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, abort, request
@@ -16,7 +16,7 @@ async def events_context() -> dict:
"""
Events app context processor.
- nav_tree_html: fetched from blog as fragment
- nav_tree: fetched from blog as fragment
- cart_count/cart_total: via cart service (shared DB)
"""
from shared.infrastructure.context import base_context
@@ -50,14 +50,14 @@ async def events_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": "events", "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

View File

@@ -14,6 +14,7 @@ from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
@@ -65,16 +66,16 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_all_events_page, render_all_events_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_all_events_page, render_all_events_oob
ctx = await get_template_context()
if is_htmx_request():
html = await render_all_events_oob(ctx, entries, has_more, pending_tickets, page_info, page, view)
sx_src = await render_all_events_oob(ctx, entries, has_more, pending_tickets, page_info, page, view)
return sx_response(sx_src)
else:
html = await render_all_events_page(ctx, entries, has_more, pending_tickets, page_info, page, view)
return await make_response(html, 200)
return await make_response(html, 200)
@bp.get("/all-entries")
async def entries_fragment():
@@ -83,9 +84,9 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page)
from sexp.sexp_components import render_all_events_cards
html = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
return await make_response(html, 200)
from sx.sx_components import render_all_events_cards
sx_src = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
return sx_response(sx_src)
@bp.post("/all-tickets/adjust")
async def adjust_ticket():
@@ -124,9 +125,9 @@ def register() -> Blueprint:
if ident["session_id"] is not None:
frag_params["session_id"] = ident["session_id"]
from sexp.sexp_components import render_ticket_widget
from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust")
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return await make_response(widget_html + mini_html, 200)
return sx_response(widget_html + (mini_html or ""))
return bp

View File

@@ -7,6 +7,7 @@ from quart import (
from shared.browser.app.authz import require_admin
from shared.browser.app.redis_cacher import clear_cache
from shared.sx.helpers import sx_response
@@ -19,24 +20,24 @@ def register():
async def admin(calendar_slug: str, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_calendar_admin_page, render_calendar_admin_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_calendar_admin_page, render_calendar_admin_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_calendar_admin_page(tctx)
return await make_response(html)
else:
html = await render_calendar_admin_oob(tctx)
return await make_response(html)
sx_src = await render_calendar_admin_oob(tctx)
return sx_response(sx_src)
@bp.get("/description/")
@require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs):
from sexp.sexp_components import render_calendar_description_edit
from sx.sx_components import render_calendar_description_edit
html = render_calendar_description_edit(g.calendar)
return await make_response(html)
return sx_response(html)
@bp.post("/description/")
@@ -50,16 +51,16 @@ def register():
g.calendar.description = description
await g.s.flush()
from sexp.sexp_components import render_calendar_description
from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar, oob=True)
return await make_response(html)
return sx_response(html)
@bp.get("/description/view/")
@require_admin
async def calendar_description_view(calendar_slug: str, **kwargs):
from sexp.sexp_components import render_calendar_description
from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar)
return await make_response(html)
return sx_response(html)
return bp

View File

@@ -24,6 +24,7 @@ from .services.calendar_view import (
update_calendar_description,
)
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from ..slots.routes import register as register_slots
@@ -79,12 +80,12 @@ def register():
async def inject_root():
from shared.infrastructure.fragments import fetch_fragment
container_nav_html = ""
container_nav = ""
post_data = getattr(g, "post_data", None)
if post_data:
post_id = post_data["post"]["id"]
post_slug = post_data["post"]["slug"]
container_nav_html = await fetch_fragment("relations", "container-nav", params={
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": post_slug,
@@ -93,7 +94,7 @@ def register():
return {
"calendar": getattr(g, "calendar", None),
"container_nav_html": container_nav_html,
"container_nav": container_nav,
}
# ---------- Pages ----------
@@ -156,8 +157,8 @@ def register():
user_entries = visible.user_entries
confirmed_entries = visible.confirmed_entries
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_calendar_page, render_calendar_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_calendar_page, render_calendar_oob
tctx = await get_template_context()
tctx.update(dict(
@@ -172,10 +173,10 @@ def register():
))
if not is_htmx_request():
html = await render_calendar_page(tctx)
return await make_response(html)
else:
html = await render_calendar_oob(tctx)
return await make_response(html)
sx_src = await render_calendar_oob(tctx)
return sx_response(sx_src)
@bp.put("/")
@@ -197,11 +198,11 @@ def register():
description = (form.get("description") or "").strip()
await update_calendar_description(g.calendar, description)
from shared.sexp.page import get_template_context
from sexp.sexp_components import _calendar_admin_main_panel_html
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
html = _calendar_admin_main_panel_html(ctx)
return await make_response(html, 200)
return sx_response(html)
@bp.delete("/")
@@ -216,14 +217,14 @@ def register():
# If we have post context (blog-embedded mode), update nav
post_data = getattr(g, "post_data", None)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_calendars_list_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel
ctx = await get_template_context()
html = render_calendars_list_panel(ctx)
if post_data:
from shared.services.entry_associations import get_associated_entries
from sexp.sexp_components import render_post_nav_entries_oob
from sx.sx_components import render_post_nav_entries_oob
post_id = (post_data.get("post") or {}).get("id")
cals = (
@@ -238,7 +239,7 @@ def register():
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob
return await make_response(html, 200)
return sx_response(html)
return bp

View File

@@ -16,6 +16,7 @@ from .services.entries import (
)
from shared.browser.app.authz import require_admin
from shared.browser.app.redis_cacher import clear_cache
from shared.sx.helpers import sx_response
from bp.calendar_entry.routes import register as register_calendar_entry
@@ -216,7 +217,7 @@ def register():
if ident["session_id"] is not None:
frag_params["session_id"] = ident["session_id"]
# Re-query day entries for the sexp component
# Re-query day entries for the sx component
from datetime import date as date_cls, timedelta
from bp.calendar.services import get_visible_entries_for_period
from quart import session as qsession
@@ -257,10 +258,10 @@ def register():
"styles": styles,
}
from sexp.sexp_components import render_day_main_panel
from sx.sx_components import render_day_main_panel
html = render_day_main_panel(ctx)
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return await make_response(html + mini_html, 200)
return sx_response(html + (mini_html or ""))
@bp.get("/add/")
async def add_form(day: int, month: int, year: int, **kwargs):

View File

@@ -28,6 +28,7 @@ import math
import logging
from shared.infrastructure.fragments import fetch_fragment
from shared.sx.helpers import sx_response
from ..ticket_types.routes import register as register_ticket_types
@@ -110,7 +111,7 @@ def register():
)
# Render OOB nav
from sexp.sexp_components import render_day_entries_nav_oob
from sx.sx_components import render_day_entries_nav_oob
return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date)
async def get_post_nav_oob(entry_id: int):
@@ -147,7 +148,7 @@ def register():
).scalars().all()
# Render OOB nav for this post
from sexp.sexp_components import render_post_nav_entries_oob
from sx.sx_components import render_post_nav_entries_oob
nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post)
nav_oobs.append(nav_oob)
@@ -216,12 +217,12 @@ def register():
)
# Fetch container nav from relations (exclude calendar — we're on a calendar page)
container_nav_html = ""
container_nav = ""
post_data = getattr(g, "post_data", None)
if post_data:
post_id = post_data["post"]["id"]
post_slug = post_data["post"]["slug"]
container_nav_html = await fetch_fragment("relations", "container-nav", params={
container_nav = await fetch_fragment("relations", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": post_slug,
@@ -235,22 +236,22 @@ def register():
"ticket_sold_count": ticket_sold_count,
"user_ticket_count": user_ticket_count,
"user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav_html": container_nav_html,
"container_nav": container_nav,
}
@bp.get("/")
@require_admin
async def get(entry_id: int, **rest):
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_entry_page, render_entry_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_entry_page, render_entry_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_entry_page(tctx)
return await make_response(html, 200)
else:
html = await render_entry_oob(tctx)
return await make_response(html, 200)
sx_src = await render_entry_oob(tctx)
return sx_response(sx_src)
@bp.get("/edit/")
@require_admin
@@ -418,12 +419,12 @@ def register():
# Get nav OOB update
nav_oob = await get_day_nav_oob(year, month, day)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_entry_page
from shared.sx.page import get_template_context
from sx.sx_components import render_entry_page
tctx = await get_template_context()
html = await render_entry_page(tctx)
return await make_response(html + nav_oob, 200)
return sx_response(html + nav_oob)
@bp.post("/confirm/")
@@ -447,9 +448,9 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sexp.sexp_components import render_entry_optioned
from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return await make_response(html + day_nav_oob + post_nav_oob, 200)
return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/decline/")
@require_admin
@@ -472,9 +473,9 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sexp.sexp_components import render_entry_optioned
from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return await make_response(html + day_nav_oob + post_nav_oob, 200)
return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/provisional/")
@require_admin
@@ -497,9 +498,9 @@ def register():
# Re-read entry to get updated state
await g.s.refresh(g.entry)
from sexp.sexp_components import render_entry_optioned
from sx.sx_components import render_entry_optioned
html = render_entry_optioned(g.entry, g.calendar, day, month, year)
return await make_response(html + day_nav_oob + post_nav_oob, 200)
return sx_response(html + day_nav_oob + post_nav_oob)
@bp.post("/tickets/")
@require_admin
@@ -541,9 +542,9 @@ def register():
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
await g.s.refresh(g.entry)
from sexp.sexp_components import render_entry_tickets_config
from sx.sx_components import render_entry_tickets_config
html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year"))
return await make_response(html, 200)
return sx_response(html)
@bp.get("/posts/search/")
@require_admin
@@ -592,11 +593,11 @@ def register():
entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update
from sexp.sexp_components import render_entry_posts_panel, render_entry_posts_nav_oob
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts)
return await make_response(html + nav_oob, 200)
return sx_response(html + nav_oob)
@bp.delete("/posts/<int:post_id>/")
@require_admin
@@ -614,10 +615,10 @@ def register():
entry_posts = await get_entry_posts(g.s, entry_id)
# Return updated posts list + OOB nav update
from sexp.sexp_components import render_entry_posts_panel, render_entry_posts_nav_oob
from sx.sx_components import render_entry_posts_panel, render_entry_posts_nav_oob
va = request.view_args or {}
html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year"))
nav_oob = render_entry_posts_nav_oob(entry_posts)
return await make_response(html + nav_oob, 200)
return sx_response(html + nav_oob)
return bp

View File

@@ -16,6 +16,7 @@ from shared.browser.app.redis_cacher import cache_page, clear_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
@@ -30,15 +31,16 @@ def register():
@bp.get("/")
@cache_page(tag="calendars")
async def home(**kwargs):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_calendars_page, render_calendars_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_page, render_calendars_oob
ctx = await get_template_context()
if not is_htmx_request():
html = await render_calendars_page(ctx)
return await make_response(html)
else:
html = await render_calendars_oob(ctx)
return await make_response(html)
sx_src = await render_calendars_oob(ctx)
return sx_response(sx_src)
@bp.post("/new/")
@@ -61,18 +63,18 @@ def register():
try:
await svc_create_calendar(g.s, post_id, name)
except Exception as e:
from shared.sexp.jinja_bridge import render as render_comp
from shared.sx.jinja_bridge import render as render_comp
return await make_response(render_comp("error-inline", message=str(e)), 422)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_calendars_list_panel
from shared.sx.page import get_template_context
from sx.sx_components import render_calendars_list_panel
ctx = await get_template_context()
html = render_calendars_list_panel(ctx)
# Blog-embedded mode: also update post nav
if post_data:
from shared.services.entry_associations import get_associated_entries
from sexp.sexp_components import render_post_nav_entries_oob
from sx.sx_components import render_post_nav_entries_oob
cals = (
await g.s.execute(
@@ -86,5 +88,5 @@ def register():
nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"])
html = html + nav_oob
return await make_response(html)
return sx_response(html)
return bp

View File

@@ -6,6 +6,7 @@ from quart import (
from shared.browser.app.authz import require_admin
from shared.sx.helpers import sx_response
def register():
@@ -17,14 +18,14 @@ def register():
async def admin(year: int, month: int, day: int, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_day_admin_page, render_day_admin_oob
from shared.sx.page import get_template_context
from sx.sx_components import render_day_admin_page, render_day_admin_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_day_admin_page(tctx)
return await make_response(html)
else:
html = await render_day_admin_oob(tctx)
return await make_response(html)
sx_src = await render_day_admin_oob(tctx)
return sx_response(sx_src)
return bp

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