32 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
bc7a4a5128 Add cross-service URL functions and rights to base_context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
blog_url, market_url, cart_url, events_url and g.rights were only
available as Jinja globals, not in the ctx dict passed to sexp
helper functions. This caused all cross-service links in the header
system (post title, cart badge, admin cog, admin nav items) to
produce relative URLs resolving to the current service domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:19:42 +00:00
8e4c2c139e Fix duplicate menu rows on HTMX navigation between depth levels
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
When navigating from a deeper page (e.g. day) to a shallower one
(e.g. calendar) via HTMX, orphaned header rows from the deeper page
persisted in the DOM because OOB swaps only replaced specific child
divs, not siblings. Fix by sending empty OOB swaps to clear all
header row IDs not present at the current depth.

Applied to events (calendars/calendar/day/entry/admin/slots) and
market (market_home/browse/product/admin). Also restore app_label
in root header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:09:15 +00:00
db3f48ec75 Remove app_label text from root header, keep settings cog
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m6s
The word "settings" (app_label) was showing next to "Rose Ash 2.0"
in the top bar. Removed that label while restoring the settings cog
icon on the right side of the menu bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:03:46 +00:00
b40f3d124c Remove settings cog from root header bar
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
The settings page is accessible via its own route; no need for a
persistent cog icon next to Rose Ash 2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:59:33 +00:00
3809affcab Test dashboard: full menu system, all-service tests, filtering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
- Run tests for all 10 services via per-service pytest subprocesses
- Group results by service with section headers
- Clickable summary cards filter by outcome (passed/failed/errors/skipped)
- Service filter nav using ~nav-link buttons in menu bar
- Full menu integration: ~header-row + ~header-child + ~menu-row
- Show logo image via cart-mini rendering
- Mount full service directories in docker-compose for test access
- Add 24 unit test files across 9 services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:54:25 +00:00
81e51ae7bc Fix settings cog URL: /settings/ not /admin/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:50:16 +00:00
b6119b7f04 Show settings cog on root header for admin users
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
Pass settings_url and is_admin to header-row component so the blog
settings cog appears on the root header row for admin users across
all services. Links to blog /admin/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:47:32 +00:00
75cb5d43b9 Apply generic admin header pattern to all events admin pages
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Events admin pages (calendars, calendar admin, day admin, entry admin,
slots, slot detail) now use shared post_admin_header_html with
selected="calendars". Container nav is fetched via fragments so post
header row matches other services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:46:00 +00:00
f628b35fc3 Make post header row generic: admin cog + container_nav in shared helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m31s
Move admin cog generation and container_nav border wrapping from
blog-specific wrapper into shared post_header_html so all services
render identical post header rows. Blog, events, cart all delegate
to the shared helper now. Cart admin pages fetch container_nav_html
via fragments. Village Hall always links to blog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:37:24 +00:00
2e4fbd5777 Remove extra cart header row from admin pages, use shared post header
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
Cart admin pages (admin overview, payments) now use the same header
pattern as blog/market/events: root_header → post_header → admin_header.
The domain name appears via app_label on the root header instead of a
separate level-1 "cart" row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:17:36 +00:00
b47ad6224b Unify post admin nav across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m56s
Move post admin header into shared/sexp/helpers.py so blog, cart,
events, and market all render the same admin row with identical nav:
calendars | markets | payments | entries | data | edit | settings.

All links are external (cross-service). The selected item shows
highlighted on the right and as white text next to "admin" on the left.

- blog: delegates to shared helper, removes blog-specific nav builder
- cart: delegates to shared helper for payments admin
- events: adds shared admin row (selected=calendars) to calendar admin
- market: adds /<slug>/admin/ route + page_admin blueprint, delegates
  to shared helper (selected=markets). Fixes 404 on page-level admin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:01:56 +00:00
2d08d6f787 Eliminate payments sub-admin row in cart, show selection on admin label
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m25s
Same pattern as blog: remove the level-3 payments header row, instead
show "payments" in white text next to "admin" on the admin row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:35:02 +00:00
beebe559cd Show selected sub-page name in white next to admin label
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m29s
Appends e.g. "settings" in white text next to the admin shield icon
on the left side of the admin row, in addition to the highlighted
nav button on the right.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:28:27 +00:00
b63aa72efb Fix admin nav selection: use !important to override text-black
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The direct bg-stone-500 text-white classes were losing to text-black
in Tailwind specificity. Use !bg-stone-500 !text-white to ensure
selected admin nav items display correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:27:02 +00:00
8cfa12de6b Eliminate post sub-admin rows, highlight active nav on admin row
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Remove the separate sub-admin header rows (data, entries, edit, settings)
that caused duplicate/stale rows on HTMX navigation and font styling breaks.
Instead, pass selected= to the admin row to highlight the active nav item
via aria-selected styling. External nav items (calendars, markets, payments)
also gain is-selected and select-colours support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:40:03 +00:00
3dd62bd9bf Bigger text in test dashboard + add deliberate failing test
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:34:19 +00:00
c926e5221d Fix test dashboard: use raw! for pre-rendered table rows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:32:40 +00:00
d62643312a Skip OAuth/auth for test service (public dashboard)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:24:07 +00:00
8852ab1108 Add test service to OAuth allowed clients
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:09:13 +00:00
369 changed files with 12682 additions and 6463 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
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) 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 if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$app/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
echo \"Building \$app...\" echo \"Building \$app...\"

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import 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 pathlib import Path
from quart import g, request from quart import g, request
@@ -44,14 +44,14 @@ async def account_context() -> dict:
if ident["session_id"] is not None: if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"] 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), ("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None), ("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "account", "path": request.path}), ("blog", "nav-tree", {"app_name": "account", "path": request.path}),
]) ])
ctx["cart_mini_html"] = cart_mini_html ctx["cart_mini"] = cart_mini
ctx["auth_menu_html"] = auth_menu_html ctx["auth_menu"] = auth_menu
ctx["nav_tree_html"] = nav_tree_html ctx["nav_tree"] = nav_tree
return ctx return ctx

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"""Account app fragment endpoints. """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. by other coop apps via the fragment client.
Fragments: Fragments:
@@ -18,18 +18,17 @@ def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Fragment handlers # Fragment handlers — return sx source text
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def _auth_menu(): async def _auth_menu():
from shared.infrastructure.urls import account_url 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", "") user_email = request.args.get("email", "")
return render_sexp( return sx_call("auth-menu",
'(~auth-menu :user-email user-email :account-url account-url)', user_email=user_email or None,
**{"user-email": user_email or None, "account-url": account_url("")}, account_url=account_url(""))
)
_handlers = { _handlers = {
"auth-menu": _auth_menu, "auth-menu": _auth_menu,
@@ -48,8 +47,8 @@ def register():
async def get_fragment(fragment_type: str): async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type) handler = _handlers.get(fragment_type)
if handler is None: if handler is None:
return Response("", status=200, content_type="text/html") return Response("", status=200, content_type="text/sx")
html = await handler() src = await handler()
return Response(html, status=200, content_type="text/html") return Response(src, status=200, content_type="text/sx")
return bp return bp

View File

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

View File

@@ -3,12 +3,12 @@
(defcomp ~account-login-error (&key error) (defcomp ~account-login-error (&key error)
(when error (when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (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" (div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in") (h1 :class "text-2xl font-bold mb-6" "Sign in")
(raw! error-html) error
(form :method "post" :action action :class "space-y-4" (form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token) (input :type "hidden" :name "csrf_token" :value csrf-token)
(div (div
@@ -22,13 +22,13 @@
(defcomp ~account-device-error (&key error) (defcomp ~account-device-error (&key error)
(when error (when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (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" (div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Authorize device") (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.") (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" (form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token) (input :type "hidden" :name "csrf_token" :value csrf-token)
(div (div
@@ -48,11 +48,11 @@
(defcomp ~account-check-email-error (&key error) (defcomp ~account-check-email-error (&key error)
(when error (when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (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" (div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email") (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.") (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) (defcomp ~account-error-banner (&key error)
(when error (when error
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" (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) (defcomp ~account-user-email (&key email)
(when 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) (defcomp ~account-user-name (&key name)
(when 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) (defcomp ~account-logout-form (&key csrf-token)
(form :action "/auth/logout/" :method "post" (form :action "/auth/logout/" :method "post"
@@ -22,27 +22,27 @@
(defcomp ~account-label-item (&key name) (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" (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) (defcomp ~account-labels-section (&key items)
(when items-html (when items
(div (div
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels") (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 "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" (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 :class "flex items-center justify-between"
(div (div
(h1 :class "text-xl font-semibold tracking-tight" "Account") (h1 :class "text-xl font-semibold tracking-tight" "Account")
(raw! email-html) email
(raw! name-html)) name)
(raw! logout-html)) logout)
(raw! labels-html)))) labels)))
;; Header child wrapper ;; 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" (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) (defcomp ~account-newsletter-desc (&key description)
(when 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) (defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
(div :id id :class "flex items-center" (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 :class cls :role "switch" :aria-checked checked
(span :class knob-cls)))) (span :class knob-cls))))
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target) (defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
(div :id id :class "flex items-center" (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" :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" :role "switch" :aria-checked "false"
(span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1")))) (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 "flex items-center justify-between py-4 first:pt-0 last:pb-0"
(div :class "min-w-0 flex-1" (div :class "min-w-0 flex-1"
(p :class "text-sm font-medium text-stone-800" (raw! name)) (p :class "text-sm font-medium text-stone-800" name)
(raw! desc-html)) desc)
(div :class "ml-4 flex-shrink-0" (raw! toggle-html)))) (div :class "ml-4 flex-shrink-0" toggle)))
(defcomp ~account-newsletter-list (&key items-html) (defcomp ~account-newsletter-list (&key items)
(div :class "divide-y divide-stone-100" (raw! items-html))) (div :class "divide-y divide-stone-100" items))
(defcomp ~account-newsletter-empty () (defcomp ~account-newsletter-empty ()
(p :class "text-sm text-stone-500" "No newsletters available.")) (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 "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" (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters") (h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
(raw! list-html)))) list)))

View File

@@ -9,13 +9,13 @@ from __future__ import annotations
import os import os
from typing import Any from typing import Any
from shared.sexp.jinja_bridge import render, load_service_components from shared.sx.jinja_bridge import load_service_components
from shared.sexp.helpers import ( from shared.sx.helpers import (
call_url, root_header_html, search_desktop_html, call_url, sx_call, SxExpr,
search_mobile_html, full_page, oob_page, 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__))) 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 # Header helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _auth_nav_html(ctx: dict) -> str: def _auth_nav_sx(ctx: dict) -> str:
"""Auth section desktop nav items.""" """Auth section desktop nav items."""
html = render( parts = [
"nav-link", sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"), href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters", label="newsletters",
select_colours=ctx.get("select_colours", ""), select_colours=ctx.get("select_colours", ""),
) )
account_nav_html = ctx.get("account_nav_html", "") ]
if account_nav_html: account_nav = ctx.get("account_nav")
html += account_nav_html if account_nav:
return html 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.""" """Build the account section header row."""
return render( return sx_call(
"menu-row", "menu-row-sx",
id="auth-row", level=1, colour="sky", id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"), link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user", 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, 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.""" """Mobile nav menu for auth section."""
html = render( parts = [
"nav-link", sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"), href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters", label="newsletters",
select_colours=ctx.get("select_colours", ""), select_colours=ctx.get("select_colours", ""),
) )
account_nav_html = ctx.get("account_nav_html", "") ]
if account_nav_html: account_nav = ctx.get("account_nav")
html += account_nav_html if account_nav:
return html parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Account dashboard (GET /) # 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.""" """Account info panel with user details and logout."""
from quart import g from quart import g
from shared.browser.app.csrf import generate_csrf_token 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) user = getattr(g, "user", None)
error = ctx.get("error", "") 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_email_sx = ""
user_name_html = "" user_name_sx = ""
if user: 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: 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: if user and hasattr(user, "labels") and user.labels:
label_items = "".join( label_items = " ".join(
render("account-label-item", name=label.name) sx_call("account-label-item", name=label.name)
for label in user.labels 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", "account-main-panel",
error_html=error_html, email_html=user_email_html, error=SxExpr(error_sx) if error_sx else None,
name_html=user_name_html, logout_html=logout_html, email=SxExpr(user_email_sx) if user_email_sx else None,
labels_html=labels_html, 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/) # 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.""" """Render a single newsletter toggle switch."""
nid = un.newsletter_id nid = un.newsletter_id
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/") 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" bg = "bg-stone-300"
translate = "translate-x-1" translate = "translate-x-1"
checked = "false" checked = "false"
return render( return sx_call(
"account-newsletter-toggle", "account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url, id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', 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).""" """Render an unsubscribed newsletter toggle (no subscription record yet)."""
return render( return sx_call(
"account-newsletter-toggle-off", "account-newsletter-toggle-off",
id=f"nl-{nid}", url=toggle_url, id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', 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.""" """Newsletters management panel."""
from shared.browser.app.csrf import generate_csrf_token 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"] nl = item["newsletter"]
un = item.get("un") un = item.get("un")
desc_html = render( desc_sx = sx_call(
"account-newsletter-desc", description=nl.description "account-newsletter-desc", description=nl.description
) if nl.description else "" ) if nl.description else ""
if un: if un:
toggle = _newsletter_toggle_html(un, account_url_fn, csrf) toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
else: else:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") 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", "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", "account-newsletter-list",
items_html="".join(items), items=SxExpr("(<> " + " ".join(items) + ")"),
) )
else: 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", "") email = ctx.get("email", "")
action = url_for("auth.start_login") 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", "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, csrf_token=generate_csrf_token(), email=email,
) )
@@ -207,18 +215,19 @@ def _device_page_content(ctx: dict) -> str:
code = ctx.get("code", "") code = ctx.get("code", "")
action = url_for("auth.device_submit") 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", "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, csrf_token=generate_csrf_token(), code=code,
) )
def _device_approved_content() -> str: def _device_approved_content() -> str:
"""Device approved success content.""" """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: async def render_account_page(ctx: dict) -> str:
"""Full page: account dashboard.""" """Full page: account dashboard."""
main = _account_main_panel_html(ctx) main = _account_main_panel_sx(ctx)
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=header_rows,
content_html=main, content=main,
menu_html=_auth_nav_mobile_html(ctx)) menu=_auth_nav_mobile_sx(ctx))
async def render_account_oob(ctx: dict) -> str: async def render_account_oob(ctx: dict) -> str:
"""OOB response for account dashboard.""" """OOB response for account dashboard."""
main = _account_main_panel_html(ctx) main = _account_main_panel_sx(ctx)
oobs = ( oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, return oob_page_sx(oobs=oobs,
content_html=main, content=main,
menu_html=_auth_nav_mobile_html(ctx)) 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: async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
"""Full page: newsletters.""" """Full page: newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list) main = _newsletters_panel_sx(ctx, newsletter_list)
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=header_rows,
content_html=main, content=main,
menu_html=_auth_nav_mobile_html(ctx)) menu=_auth_nav_mobile_sx(ctx))
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
"""OOB response for newsletters.""" """OOB response for newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list) main = _newsletters_panel_sx(ctx, newsletter_list)
oobs = ( oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, return oob_page_sx(oobs=oobs,
content_html=main, content=main,
menu_html=_auth_nav_mobile_html(ctx)) 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: async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content.""" """Full page: fragment-provided content."""
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=header_rows,
content_html=page_fragment_html, content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu_html=_auth_nav_mobile_html(ctx)) menu=_auth_nav_mobile_sx(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
"""OOB response for fragment pages.""" """OOB response for fragment pages."""
oobs = ( oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, return oob_page_sx(oobs=oobs,
content_html=page_fragment_html, content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu_html=_auth_nav_mobile_html(ctx)) menu=_auth_nav_mobile_sx(ctx))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -313,25 +316,25 @@ async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
async def render_login_page(ctx: dict) -> str: async def render_login_page(ctx: dict) -> str:
"""Full page: login form.""" """Full page: login form."""
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=hdr,
content_html=_login_page_content(ctx), content=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>') meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str: async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form.""" """Full page: device authorization form."""
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=hdr,
content_html=_device_page_content(ctx), content=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>') meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str: async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved.""" """Full page: device approved."""
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=hdr,
content_html=_device_approved_content(), content=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>') 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.""" """Check email confirmation content."""
from markupsafe import escape from markupsafe import escape
error_html = render( error_sx = sx_call(
"account-check-email-error", error=str(escape(email_error)) "account-check-email-error", error=str(escape(email_error))
) if email_error else "" ) if email_error else ""
return render( return sx_call(
"account-check-email", "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,9 +361,9 @@ async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent.""" """Full page: check email after magic link sent."""
email = ctx.get("email", "") email = ctx.get("email", "")
email_error = ctx.get("email_error") email_error = ctx.get("email_error")
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=hdr,
content_html=_check_email_content(email, email_error), content=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>') meta_html='<title>Check your email \u2014 Rose Ash</title>')
@@ -367,12 +371,6 @@ async def render_check_email_page(ctx: dict) -> str:
# Public API: Fragment renderers for POST handlers # 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: def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response (uses account_url).""" """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: if account_url_fn is None:
from shared.infrastructure.urls import account_url from shared.infrastructure.urls import account_url
account_url_fn = 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"> <div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button <button
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}" sx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}" sx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML" 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 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 %}" {% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch" role="switch"

View File

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

View File

@@ -0,0 +1,39 @@
"""Unit tests for account auth operations."""
from __future__ import annotations
import pytest
from account.bp.auth.services.auth_operations import validate_email
class TestValidateEmail:
def test_valid_email(self):
ok, email = validate_email("user@example.com")
assert ok is True
assert email == "user@example.com"
def test_uppercase_lowered(self):
ok, email = validate_email("USER@EXAMPLE.COM")
assert ok is True
assert email == "user@example.com"
def test_whitespace_stripped(self):
ok, email = validate_email(" user@example.com ")
assert ok is True
assert email == "user@example.com"
def test_empty_string(self):
ok, email = validate_email("")
assert ok is False
def test_no_at_sign(self):
ok, email = validate_email("notanemail")
assert ok is False
def test_just_at(self):
ok, email = validate_email("@")
assert ok is True # has "@", passes the basic check
def test_spaces_only(self):
ok, email = validate_email(" ")
assert ok is False

View File

@@ -0,0 +1,164 @@
"""Unit tests for Ghost membership helpers."""
from __future__ import annotations
from datetime import datetime
import pytest
from account.services.ghost_membership import (
_iso, _to_str_or_none, _member_email,
_price_cents, _sanitize_member_payload,
)
class TestIso:
def test_none(self):
assert _iso(None) is None
def test_empty(self):
assert _iso("") is None
def test_z_suffix(self):
result = _iso("2024-06-15T12:00:00Z")
assert isinstance(result, datetime)
assert result.year == 2024
def test_offset(self):
result = _iso("2024-06-15T12:00:00+00:00")
assert isinstance(result, datetime)
class TestToStrOrNone:
def test_none(self):
assert _to_str_or_none(None) is None
def test_dict(self):
assert _to_str_or_none({"a": 1}) is None
def test_list(self):
assert _to_str_or_none([1, 2]) is None
def test_bytes(self):
assert _to_str_or_none(b"hello") is None
def test_empty_string(self):
assert _to_str_or_none("") is None
def test_whitespace_only(self):
assert _to_str_or_none(" ") is None
def test_valid_string(self):
assert _to_str_or_none("hello") == "hello"
def test_int(self):
assert _to_str_or_none(42) == "42"
def test_strips_whitespace(self):
assert _to_str_or_none(" hi ") == "hi"
def test_set(self):
assert _to_str_or_none({1, 2}) is None
def test_tuple(self):
assert _to_str_or_none((1,)) is None
def test_bytearray(self):
assert _to_str_or_none(bytearray(b"x")) is None
class TestMemberEmail:
def test_normal(self):
assert _member_email({"email": "USER@EXAMPLE.COM"}) == "user@example.com"
def test_none(self):
assert _member_email({"email": None}) is None
def test_empty(self):
assert _member_email({"email": ""}) is None
def test_whitespace(self):
assert _member_email({"email": " "}) is None
def test_missing_key(self):
assert _member_email({}) is None
def test_strips(self):
assert _member_email({"email": " a@b.com "}) == "a@b.com"
class TestPriceCents:
def test_valid(self):
assert _price_cents({"price": {"amount": 1500}}) == 1500
def test_string_amount(self):
assert _price_cents({"price": {"amount": "2000"}}) == 2000
def test_missing_price(self):
assert _price_cents({}) is None
def test_missing_amount(self):
assert _price_cents({"price": {}}) is None
def test_none_amount(self):
assert _price_cents({"price": {"amount": None}}) is None
def test_nested_none(self):
assert _price_cents({"price": None}) is None
class TestSanitizeMemberPayload:
def test_email_lowercased(self):
result = _sanitize_member_payload({"email": "USER@EXAMPLE.COM"})
assert result["email"] == "user@example.com"
def test_empty_email_excluded(self):
result = _sanitize_member_payload({"email": ""})
assert "email" not in result
def test_name_included(self):
result = _sanitize_member_payload({"name": "Alice"})
assert result["name"] == "Alice"
def test_note_included(self):
result = _sanitize_member_payload({"note": "VIP"})
assert result["note"] == "VIP"
def test_subscribed_bool(self):
result = _sanitize_member_payload({"subscribed": 1})
assert result["subscribed"] is True
def test_labels_with_id(self):
result = _sanitize_member_payload({
"labels": [{"id": "abc"}, {"name": "VIP"}]
})
assert result["labels"] == [{"id": "abc"}, {"name": "VIP"}]
def test_labels_empty_items_excluded(self):
result = _sanitize_member_payload({
"labels": [{"id": None, "name": None}]
})
assert "labels" not in result
def test_newsletters_with_id(self):
result = _sanitize_member_payload({
"newsletters": [{"id": "n1", "subscribed": True}]
})
assert result["newsletters"] == [{"subscribed": True, "id": "n1"}]
def test_newsletters_default_subscribed(self):
result = _sanitize_member_payload({
"newsletters": [{"name": "Weekly"}]
})
assert result["newsletters"][0]["subscribed"] is True
def test_dict_email_excluded(self):
result = _sanitize_member_payload({"email": {"bad": "input"}})
assert "email" not in result
def test_id_passthrough(self):
result = _sanitize_member_payload({"id": "ghost-member-123"})
assert result["id"] == "ghost-member-123"
def test_empty_payload(self):
result = _sanitize_member_payload({})
assert result == {}

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import 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 pathlib import Path
from quart import g, request from quart import g, request
@@ -27,7 +27,7 @@ async def blog_context() -> dict:
Blog app context processor. Blog app context processor.
- cart_count/cart_total: via cart service (shared DB) - 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.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree 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 {} auth_params = {"email": user.email} if user else {}
nav_params = {"app_name": "blog", "path": request.path} 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), ("cart", "cart-mini", cart_params or None),
("account", "auth-menu", auth_params or None), ("account", "auth-menu", auth_params or None),
("blog", "nav-tree", nav_params), ("blog", "nav-tree", nav_params),
]) ])
ctx["cart_mini_html"] = cart_mini_html ctx["cart_mini"] = cart_mini
ctx["auth_menu_html"] = auth_menu_html ctx["auth_menu"] = auth_menu
ctx["nav_tree_html"] = nav_tree_html ctx["nav_tree"] = nav_tree
return ctx 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.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.config import config from shared.config import config
from datetime import datetime from datetime import datetime
@@ -29,29 +30,30 @@ def register(url_prefix):
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def home(): async def home():
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_settings_page, render_settings_oob from sx.sx_components import render_settings_page, render_settings_oob
tctx = await get_template_context() tctx = await get_template_context()
if not is_htmx_request(): if not is_htmx_request():
html = await render_settings_page(tctx) html = await render_settings_page(tctx)
else:
html = await render_settings_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_settings_oob(tctx)
return sx_response(sx_src)
@bp.get("/cache/") @bp.get("/cache/")
@require_admin @require_admin
async def cache(): async def cache():
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_cache_page, render_cache_oob from sx.sx_components import render_cache_page, render_cache_oob
tctx = await get_template_context() tctx = await get_template_context()
if not is_htmx_request(): if not is_htmx_request():
html = await render_cache_page(tctx) html = await render_cache_page(tctx)
else:
html = await render_cache_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_cache_oob(tctx)
return sx_response(sx_src)
@bp.post("/cache_clear/") @bp.post("/cache_clear/")
@require_admin @require_admin
@@ -59,9 +61,9 @@ def register(url_prefix):
await clear_all_cache() await clear_all_cache()
if is_htmx_request(): if is_htmx_request():
now = datetime.now() 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")) 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 redirect(url_for("settings.cache"))
return bp 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.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache from shared.browser.app.redis_cacher import invalidate_tag_cache
from shared.sx.helpers import sx_response
from models.tag_group import TagGroup, TagGroupTag from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag from models.ghost_content import Tag
@@ -57,15 +58,15 @@ def register():
ctx = {"groups": groups, "unassigned_tags": unassigned} ctx = {"groups": groups, "unassigned_tags": unassigned}
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_tag_groups_page, render_tag_groups_oob from sx.sx_components import render_tag_groups_page, render_tag_groups_oob
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(ctx) tctx.update(ctx)
if not is_htmx_request(): if not is_htmx_request():
return await make_response(await render_tag_groups_page(tctx)) return await make_response(await render_tag_groups_page(tctx))
else: else:
return await make_response(await render_tag_groups_oob(tctx)) return sx_response(await render_tag_groups_oob(tctx))
@bp.post("/") @bp.post("/")
@require_admin @require_admin
@@ -122,15 +123,15 @@ def register():
"assigned_tag_ids": assigned_tag_ids, "assigned_tag_ids": assigned_tag_ids,
} }
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob from sx.sx_components import render_tag_group_edit_page, render_tag_group_edit_oob
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(ctx) tctx.update(ctx)
if not is_htmx_request(): if not is_htmx_request():
return await make_response(await render_tag_group_edit_page(tctx)) return await make_response(await render_tag_group_edit_page(tctx))
else: 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>/") @bp.post("/<int:id>/")
@require_admin @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.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.sx.helpers import sx_response
from shared.utils import host_url from shared.utils import host_url
def register(url_prefix, title): def register(url_prefix, title):
@@ -117,7 +118,7 @@ def register(url_prefix, title):
post_slug = p_data["post"]["slug"] post_slug = p_data["post"]["slug"]
# Fetch container nav from relations service # 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_type": "page",
"container_id": str(db_post_id), "container_id": str(db_post_id),
"post_slug": post_slug, "post_slug": post_slug,
@@ -126,7 +127,7 @@ def register(url_prefix, title):
ctx = { ctx = {
**p_data, **p_data,
"base_title": get_config()["title"], "base_title": get_config()["title"],
"container_nav_html": container_nav_html, "container_nav": container_nav,
} }
# Page cart badge via HTTP # 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_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) 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 shared.sx.page import get_template_context
from sexp.sexp_components import render_home_page, render_home_oob from sx.sx_components import render_home_page, render_home_oob
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(ctx) tctx.update(ctx)
if not is_htmx_request(): if not is_htmx_request():
html = await render_home_page(tctx) html = await render_home_page(tctx)
else:
html = await render_home_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_home_oob(tctx)
return sx_response(sx_src)
@blogs_bp.get("/index") @blogs_bp.get("/index")
@blogs_bp.get("/index/") @blogs_bp.get("/index/")
@@ -179,18 +181,20 @@ def register(url_prefix, title):
"tag_groups": [], "tag_groups": [],
"posts": data.get("pages", []), "posts": data.get("pages", []),
} }
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards from sx.sx_components import render_blog_page, render_blog_oob, render_blog_page_cards
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(context) tctx.update(context)
if not is_htmx_request(): if not is_htmx_request():
html = await render_blog_page(tctx) html = await render_blog_page(tctx)
elif q.page > 1:
html = await render_blog_page_cards(tctx)
else:
html = await render_blog_oob(tctx)
return await make_response(html) return await make_response(html)
elif q.page > 1:
sx_src = await render_blog_page_cards(tctx)
return sx_response(sx_src)
else:
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
# Default: posts listing # Default: posts listing
# Drafts filter requires login; ignore if not logged in # 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, "drafts": q.drafts if show_drafts else None,
} }
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_cards from sx.sx_components import render_blog_page, render_blog_oob, render_blog_cards
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(context) tctx.update(context)
if not is_htmx_request(): if not is_htmx_request():
html = await render_blog_page(tctx) html = await render_blog_page(tctx)
elif q.page > 1:
html = await render_blog_cards(tctx)
else:
html = await render_blog_oob(tctx)
return await make_response(html) return await make_response(html)
elif q.page > 1:
# Sx wire format — client renders blog cards
sx_src = await render_blog_cards(tctx)
return sx_response(sx_src)
else:
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
@blogs_bp.get("/new/") @blogs_bp.get("/new/")
@require_admin @require_admin
async def new_post(): async def new_post():
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel() tctx["editor_html"] = render_editor_panel()
if not is_htmx_request(): if not is_htmx_request():
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
else:
html = await render_new_post_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_new_post_oob(tctx)
return sx_response(sx_src)
@blogs_bp.post("/new/") @blogs_bp.post("/new/")
@require_admin @require_admin
@@ -267,8 +274,8 @@ def register(url_prefix, title):
try: try:
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.") tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
@@ -276,8 +283,8 @@ def register(url_prefix, title):
ok, reason = validate_lexical(lexical_doc) ok, reason = validate_lexical(lexical_doc)
if not ok: if not ok:
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason) tctx["editor_html"] = render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
@@ -317,17 +324,18 @@ def register(url_prefix, title):
@blogs_bp.get("/new-page/") @blogs_bp.get("/new-page/")
@require_admin @require_admin
async def new_page(): async def new_page():
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(is_page=True) tctx["editor_html"] = render_editor_panel(is_page=True)
tctx["is_page"] = True tctx["is_page"] = True
if not is_htmx_request(): if not is_htmx_request():
html = await render_new_post_page(tctx) html = await render_new_post_page(tctx)
else:
html = await render_new_post_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_new_post_oob(tctx)
return sx_response(sx_src)
@blogs_bp.post("/new-page/") @blogs_bp.post("/new-page/")
@require_admin @require_admin
@@ -348,8 +356,8 @@ def register(url_prefix, title):
try: try:
lexical_doc = json.loads(lexical_raw) lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True) tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["is_page"] = True tctx["is_page"] = True
@@ -358,8 +366,8 @@ def register(url_prefix, title):
ok, reason = validate_lexical(lexical_doc) ok, reason = validate_lexical(lexical_doc)
if not ok: if not ok:
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel from sx.sx_components import render_new_post_page, render_editor_panel
tctx = await get_template_context() tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True) tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
tctx["is_page"] = True tctx["is_page"] = True

View File

@@ -1,6 +1,6 @@
"""Blog app fragment endpoints. """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. 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.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.navigation import get_navigation_tree from shared.services.navigation import get_navigation_tree
from shared.sexp.jinja_bridge import sexp
def register(): def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# Registry of fragment handlers: type -> async callable returning HTML str
_handlers: dict[str, object] = {} _handlers: dict[str, object] = {}
@bp.before_request @bp.before_request
@@ -28,50 +26,103 @@ def register():
async def get_fragment(fragment_type: str): async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type) handler = _handlers.get(fragment_type)
if handler is None: if handler is None:
return Response("", status=200, content_type="text/html") return Response("", status=200, content_type="text/sx")
html = await handler() result = await handler()
return Response(html, status=200, content_type="text/html") return Response(result, status=200, content_type="text/sx")
# --- nav-tree fragment --- # --- nav-tree fragment — returns sx source ---
async def _nav_tree_handler(): 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", "") app_name = request.args.get("app_name", "")
path = request.args.get("path", "/") path = request.args.get("path", "/")
first_seg = path.strip("/").split("/")[0] first_seg = path.strip("/").split("/")[0]
menu_items = list(await get_navigation_tree(g.s)) menu_items = list(await get_navigation_tree(g.s))
# Append Art-DAG as a synthetic nav entry (not a DB MenuNode) app_slugs = {
class _NavItem: "cart": cart_url("/"),
__slots__ = ("slug", "label", "feature_image") "market": market_url("/"),
def __init__(self, slug, label, feature_image=None): "events": events_url("/"),
self.slug = slug "federation": federation_url("/"),
self.label = label "account": account_url("/"),
self.feature_image = feature_image "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( item_sxs = []
"fragments/nav_tree.html", for item in menu_items:
menu_items=menu_items, href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/"))
frag_app_name=app_name, selected = "true" if (item.slug == first_seg
frag_first_seg=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 _handlers["nav-tree"] = _nav_tree_handler
# --- link-card fragment (s-expression rendered) --- # --- link-card fragment — returns sx source ---
def _render_blog_link_card(post, link: str) -> str: def _blog_link_card_sx(post, link: str) -> str:
"""Render a blog link-card via the ~link-card s-expression component.""" from shared.sx.helpers import sx_call
published = post.published_at.strftime("%d %b %Y") if post.published_at else None published = post.published_at.strftime("%d %b %Y") if post.published_at else None
return sexp( return sx_call("link-card",
'(~link-card :link link :title title :image image'
' :icon "fas fa-file-alt" :subtitle excerpt'
' :detail published :data-app "blog")',
link=link, link=link,
title=post.title, title=post.title,
image=post.feature_image, image=post.feature_image,
excerpt=post.custom_excerpt or post.excerpt, icon="fas fa-file-alt",
published=published, subtitle=post.custom_excerpt or post.excerpt,
) detail=published,
data_app="blog")
async def _link_card_handler(): async def _link_card_handler():
from shared.services.registry import services from shared.services.registry import services
@@ -88,7 +139,7 @@ def register():
parts.append(f"<!-- fragment:{s} -->") parts.append(f"<!-- fragment:{s} -->")
post = await services.blog.get_post_by_slug(g.s, s) post = await services.blog.get_post_by_slug(g.s, s)
if post: 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) return "\n".join(parts)
# Single mode # Single mode
@@ -97,11 +148,10 @@ def register():
post = await services.blog.get_post_by_slug(g.s, slug) post = await services.blog.get_post_by_slug(g.s, slug)
if not post: if not post:
return "" 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 _handlers["link-card"] = _link_card_handler
# Store handlers dict on blueprint so app code can register handlers
bp._fragment_handlers = _handlers bp._fragment_handlers = _handlers
return bp return bp

View File

@@ -13,13 +13,14 @@ from .services.menu_items import (
MenuItemError, MenuItemError,
) )
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items') bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
def get_menu_items_nav_oob_sync(menu_items): def get_menu_items_nav_oob_sync(menu_items):
"""Helper to generate OOB update for root nav menu items""" """Helper to generate OOB update for root nav menu items"""
from 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) return render_menu_items_nav_oob(menu_items)
@bp.get("/") @bp.get("/")
@@ -29,17 +30,17 @@ def register():
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_menu_items_page, render_menu_items_oob from sx.sx_components import render_menu_items_page, render_menu_items_oob
tctx = await get_template_context() tctx = await get_template_context()
tctx["menu_items"] = menu_items tctx["menu_items"] = menu_items
if not is_htmx_request(): if not is_htmx_request():
html = await render_menu_items_page(tctx) html = await render_menu_items_page(tctx)
else:
html = await render_menu_items_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_menu_items_oob(tctx)
return sx_response(sx_src)
@bp.get("/new/") @bp.get("/new/")
@require_admin @require_admin
@@ -72,10 +73,10 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200) return sx_response(html + nav_oob)
except MenuItemError as e: except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400 return jsonify({"message": str(e), "errors": {}}), 400
@@ -115,10 +116,10 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200) return sx_response(html + nav_oob)
except MenuItemError as e: except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400 return jsonify({"message": str(e), "errors": {}}), 400
@@ -136,10 +137,10 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200) return sx_response(html + nav_oob)
@bp.get("/pages/search/") @bp.get("/pages/search/")
@require_admin @require_admin
@@ -183,9 +184,9 @@ def register():
# Get updated list and nav OOB # Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
from sexp.sexp_components import render_menu_items_list from sx.sx_components import render_menu_items_list
html = render_menu_items_list(menu_items) html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items) nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200) return sx_response(html + nav_oob)
return bp 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.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from shared.utils import host_url from shared.utils import host_url
def register(): def register():
@@ -51,17 +52,17 @@ def register():
"sumup_checkout_prefix": sumup_checkout_prefix, "sumup_checkout_prefix": sumup_checkout_prefix,
} }
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_post_admin_page, render_post_admin_oob from sx.sx_components import render_post_admin_page, render_post_admin_oob
tctx = await get_template_context() tctx = await get_template_context()
tctx.update(ctx) tctx.update(ctx)
if not is_htmx_request(): if not is_htmx_request():
html = await render_post_admin_page(tctx) html = await render_post_admin_page(tctx)
else:
html = await render_post_admin_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_post_admin_oob(tctx)
return sx_response(sx_src)
@bp.put("/features/") @bp.put("/features/")
@require_admin @require_admin
@@ -98,14 +99,14 @@ def register():
features = result.get("features", {}) features = result.get("features", {})
from sexp.sexp_components import render_features_panel from sx.sx_components import render_features_panel
html = render_features_panel( html = render_features_panel(
features, post, features, post,
sumup_configured=result.get("sumup_configured", False), sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "", sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "", sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
) )
return await make_response(html) return sx_response(html)
@bp.put("/admin/sumup/") @bp.put("/admin/sumup/")
@require_admin @require_admin
@@ -137,30 +138,30 @@ def register():
result = await call_action("blog", "update-page-config", payload=payload) result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {}) features = result.get("features", {})
from sexp.sexp_components import render_features_panel from sx.sx_components import render_features_panel
html = render_features_panel( html = render_features_panel(
features, post, features, post,
sumup_configured=result.get("sumup_configured", False), sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "", sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "", sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
) )
return await make_response(html) return sx_response(html)
@bp.get("/data/") @bp.get("/data/")
@require_admin @require_admin
async def data(slug: str): async def data(slug: str):
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_post_data_page, render_post_data_oob from sx.sx_components import render_post_data_page, render_post_data_oob
data_html = await render_template("_types/post_data/_main_panel.html") data_html = await render_template("_types/post_data/_main_panel.html")
tctx = await get_template_context() tctx = await get_template_context()
tctx["data_html"] = data_html tctx["data_html"] = data_html
if not is_htmx_request(): if not is_htmx_request():
html = await render_post_data_page(tctx) html = await render_post_data_page(tctx)
else:
html = await render_post_data_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_post_data_oob(tctx)
return sx_response(sx_src)
@bp.get("/entries/calendar/<int:calendar_id>/") @bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin @require_admin
@@ -268,8 +269,8 @@ def register():
# Load entries and post for each calendar # Load entries and post for each calendar
for calendar in all_calendars: for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"]) await g.s.refresh(calendar, ["entries", "post"])
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_post_entries_page, render_post_entries_oob from sx.sx_components import render_post_entries_page, render_post_entries_oob
entries_html = await render_template( entries_html = await render_template(
"_types/post_entries/_main_panel.html", "_types/post_entries/_main_panel.html",
@@ -280,10 +281,10 @@ def register():
tctx["entries_html"] = entries_html tctx["entries_html"] = entries_html
if not is_htmx_request(): if not is_htmx_request():
html = await render_post_entries_page(tctx) html = await render_post_entries_page(tctx)
else:
html = await render_post_entries_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_post_entries_oob(tctx)
return sx_response(sx_src)
@bp.post("/entries/<int:entry_id>/toggle/") @bp.post("/entries/<int:entry_id>/toggle/")
@require_admin @require_admin
@@ -329,13 +330,13 @@ def register():
).scalars().all() ).scalars().all()
# Return the associated entries admin list + OOB update for nav entries # Return the associated entries admin list + OOB update for nav entries
from 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"] post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"]) admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post) nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
return await make_response(admin_list + nav_entries_html) return sx_response(admin_list + nav_entries_html)
@bp.get("/settings/") @bp.get("/settings/")
@require_post_author @require_post_author
@@ -347,8 +348,8 @@ def register():
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page) ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1" save_success = request.args.get("saved") == "1"
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_post_settings_page, render_post_settings_oob from sx.sx_components import render_post_settings_page, render_post_settings_oob
settings_html = await render_template( settings_html = await render_template(
"_types/post_settings/_main_panel.html", "_types/post_settings/_main_panel.html",
@@ -359,10 +360,10 @@ def register():
tctx["settings_html"] = settings_html tctx["settings_html"] = settings_html
if not is_htmx_request(): if not is_htmx_request():
html = await render_post_settings_page(tctx) html = await render_post_settings_page(tctx)
else:
html = await render_post_settings_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_post_settings_oob(tctx)
return sx_response(sx_src)
@bp.post("/settings/") @bp.post("/settings/")
@require_post_author @require_post_author
@@ -451,8 +452,8 @@ def register():
from types import SimpleNamespace from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_post_edit_page, render_post_edit_oob from sx.sx_components import render_post_edit_page, render_post_edit_oob
edit_html = await render_template( edit_html = await render_template(
"_types/post_edit/_main_panel.html", "_types/post_edit/_main_panel.html",
@@ -465,10 +466,10 @@ def register():
tctx["edit_html"] = edit_html tctx["edit_html"] = edit_html
if not is_htmx_request(): if not is_htmx_request():
html = await render_post_edit_page(tctx) html = await render_post_edit_page(tctx)
else:
html = await render_post_edit_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_post_edit_oob(tctx)
return sx_response(sx_src)
@bp.post("/edit/") @bp.post("/edit/")
@require_post_author @require_post_author
@@ -597,9 +598,8 @@ def register():
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel from sx.sx_components import render_markets_panel
html = render_markets_panel(page_markets, post) return sx_response(render_markets_panel(page_markets, post))
return await make_response(html)
@bp.post("/markets/new/") @bp.post("/markets/new/")
@require_admin @require_admin
@@ -624,9 +624,8 @@ def register():
# Return updated markets list # Return updated markets list
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel from sx.sx_components import render_markets_panel
html = render_markets_panel(page_markets, post) return sx_response(render_markets_panel(page_markets, post))
return await make_response(html)
@bp.delete("/markets/<market_slug>/") @bp.delete("/markets/<market_slug>/")
@require_admin @require_admin
@@ -645,8 +644,7 @@ def register():
# Return updated markets list # Return updated markets list
page_markets = await _fetch_page_markets(post_id) page_markets = await _fetch_page_markets(post_id)
from sexp.sexp_components import render_markets_panel from sx.sx_components import render_markets_panel
html = render_markets_panel(page_markets, post) return sx_response(render_markets_panel(page_markets, post))
return await make_response(html)
return bp 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 .admin.routes import register as register_admin
from shared.config import config from shared.config import config
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register(): def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>') bp = Blueprint("post", __name__, url_prefix='/<slug>')
@@ -70,7 +71,7 @@ def register():
post_slug = (g.post_data.get("post") or {}).get("slug", "") post_slug = (g.post_data.get("post") or {}).get("slug", "")
# Fetch container nav from relations service # 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_type": "page",
"container_id": str(db_post_id), "container_id": str(db_post_id),
"post_slug": post_slug, "post_slug": post_slug,
@@ -79,7 +80,7 @@ def register():
ctx = { ctx = {
**p_data, **p_data,
"base_title": config()["title"], "base_title": config()["title"],
"container_nav_html": container_nav_html, "container_nav": container_nav,
} }
# Page cart badge via HTTP # Page cart badge via HTTP
@@ -103,30 +104,28 @@ def register():
@bp.get("/") @bp.get("/")
@cache_page(tag="post.post_detail") @cache_page(tag="post.post_detail")
async def post_detail(slug: str): async def post_detail(slug: str):
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_post_page, render_post_oob from sx.sx_components import render_post_page, render_post_oob
tctx = await get_template_context() tctx = await get_template_context()
if not is_htmx_request(): if not is_htmx_request():
html = await render_post_page(tctx) html = await render_post_page(tctx)
else:
html = await render_post_oob(tctx)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_post_oob(tctx)
return sx_response(sx_src)
@bp.post("/like/toggle/") @bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user") @clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str): async def like_toggle(slug: str):
from shared.utils import host_url from shared.utils import host_url
from 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)) like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
# Get post_id from g.post_data # Get post_id from g.post_data
if not g.user: if not g.user:
html = render_like_toggle_button(slug, False, like_url) return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
resp = make_response(html, 403)
return resp
post_id = g.post_data["post"]["id"] post_id = g.post_data["post"]["id"]
user_id = g.user.id user_id = g.user.id
@@ -136,8 +135,7 @@ def register():
}) })
liked = result["liked"] liked = result["liked"]
html = render_like_toggle_button(slug, liked, like_url) return sx_response(render_like_toggle_button(slug, liked, like_url))
return html
@bp.get("/w/<widget_domain>/") @bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str): async def widget_paginate(slug: str, widget_domain: str):

View File

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

View File

@@ -54,6 +54,7 @@ fi
RELOAD_FLAG="" RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload" RELOAD_FLAG="--reload"
python3 -m shared.dev_watcher &
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
else else
echo "Starting Hypercorn (${APP_MODULE:-app:app})..." 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) (defcomp ~blog-cache-panel (&key clear-url csrf)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6" (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" (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) (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")) (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")))) (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 "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-between items-center" (div :class "mb-6 flex justify-between items-center"
(h1 :class "text-3xl font-bold" "Snippets")) (h1 :class "text-3xl font-bold" "Snippets"))
(div :id "snippets-list" (raw! list-html)))) (div :id "snippets-list" list)))
(defcomp ~blog-snippets-empty () (defcomp ~blog-snippets-empty ()
(div :class "bg-white rounded-lg shadow" (div :class "bg-white rounded-lg shadow"
@@ -20,10 +20,10 @@
(i :class "fa fa-puzzle-piece text-4xl mb-2") (i :class "fa fa-puzzle-piece text-4xl mb-2")
(p "No snippets yet. Create one from the blog editor.")))) (p "No snippets yet. Create one from the blog editor."))))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options-html cls) (defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
(select :name "visibility" :hx-patch patch-url :hx-target "#snippets-list" :hx-swap "innerHTML" (select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
:hx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1" :sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
(raw! options-html))) options))
(defcomp ~blog-snippet-option (&key value selected label) (defcomp ~blog-snippet-option (&key value selected label)
(option :value value :selected selected label)) (option :value value :selected selected label))
@@ -33,30 +33,30 @@
:data-confirm-text confirm-text :data-confirm-icon "warning" :data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel" :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed" :data-confirm-event "confirmed"
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML" :sx-delete delete-url :sx-trigger "confirmed" :sx-target "#snippets-list" :sx-swap "innerHTML"
:hx-headers hx-headers :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" :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")) (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 items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name) (div :class "font-medium truncate" name)
(div :class "text-xs text-stone-500" owner)) (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) (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) (defcomp ~blog-snippets-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html)))) (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 "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-end items-center" (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" :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
(i :class "fa fa-plus") " Add Menu Item")) (i :class "fa fa-plus") " Add Menu Item"))
(div :id "menu-item-form" :class "mb-6") (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 () (defcomp ~blog-menu-items-empty ()
(div :class "bg-white rounded-lg shadow" (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") (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"))) (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 "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")) (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 "flex-1 min-w-0"
(div :class "font-medium truncate" label) (div :class "font-medium truncate" label)
(div :class "text-xs text-stone-500 truncate" slug)) (div :class "text-xs text-stone-500 truncate" slug))
(div :class "text-sm text-stone-500" (str "Order: " sort-order)) (div :class "text-sm text-stone-500" (str "Order: " sort-order))
(div :class "flex gap-2 flex-shrink-0" (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" :class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
(i :class "fa fa-edit") " Edit") (i :class "fa fa-edit") " Edit")
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?" (button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
:data-confirm-text confirm-text :data-confirm-icon "warning" :data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel" :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed" :data-confirm-event "confirmed"
:hx-delete delete-url :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML" :sx-delete delete-url :sx-trigger "confirmed" :sx-target "#menu-items-list" :sx-swap "innerHTML"
:hx-headers hx-headers :sx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800" :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
(i :class "fa fa-trash") " Delete")))) (i :class "fa fa-trash") " Delete"))))
(defcomp ~blog-menu-items-list (&key rows-html) (defcomp ~blog-menu-items-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html)))) (div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
;; Tag groups admin ;; 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" (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)) :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" (li :class "border rounded p-3 bg-white flex items-center gap-3"
(raw! icon-html) icon
(div :class "flex-1" (div :class "flex-1"
(a :href edit-href :class "font-medium text-stone-800 hover:underline" name) (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 ml-2" slug))
(span :class "text-xs text-stone-500" (str "order: " sort-order)))) (span :class "text-xs text-stone-500" (str "order: " sort-order))))
(defcomp ~blog-tag-groups-list (&key items-html) (defcomp ~blog-tag-groups-list (&key items)
(ul :class "space-y-2" (raw! items-html))) (ul :class "space-y-2" items))
(defcomp ~blog-tag-groups-empty () (defcomp ~blog-tag-groups-empty ()
(p :class "text-stone-500 text-sm" "No tag groups yet.")) (p :class "text-stone-500 text-sm" "No tag groups yet."))
@@ -129,26 +129,26 @@
(defcomp ~blog-unassigned-tag (&key name) (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)) (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" (div :class "border-t pt-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading) (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" (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 ;; 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" (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") (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) (defcomp ~blog-tag-checkbox-image (&key src)
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover")) (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" (form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(div :class "space-y-3" (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"))) (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 (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" (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" (div :class "flex gap-3"
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save")))) (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) (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"))) (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" (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 ;; Blog post detail components
(defcomp ~blog-detail-edit-link (&key href hx-select) (defcomp ~blog-detail-edit-link (&key href hx-select)
(a :href href :hx-get href :hx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" :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" :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")) (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" (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") (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")) (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) (defcomp ~blog-detail-like (&key like-url hx-headers heart)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl" (div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(button :hx-post like-url :hx-swap "outerHTML" (button :sx-post like-url :sx-swap "outerHTML"
:hx-headers hx-headers :class "cursor-pointer" heart))) :sx-headers hx-headers :class "cursor-pointer" heart)))
(defcomp ~blog-detail-excerpt (&key excerpt) (defcomp ~blog-detail-excerpt (&key excerpt)
(div :class "w-full text-center italic text-3xl p-2" excerpt)) (div :class "w-full text-center italic text-3xl p-2" excerpt))
(defcomp ~blog-detail-chrome (&key like-html excerpt-html at-bar-html) (defcomp ~blog-detail-chrome (&key like excerpt at-bar)
(<> (raw! like-html) (raw! excerpt-html) (div :class "hidden md:block" (raw! at-bar-html)))) (<> 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" (<> (article :class "relative"
(raw! draft-html) (raw! chrome-html) draft
chrome
(when feature-image (div :class "mb-3 flex justify-center" (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"))) (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"))) (div :class "pb-8")))
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title) (defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
@@ -48,7 +51,7 @@
(when image (meta :name "twitter:image" :content image)))) (when image (meta :name "twitter:image" :content image))))
(defcomp ~blog-home-main (&key html-content) (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 () (defcomp ~blog-admin-empty ()
(div :class "pb-8")) (div :class "pb-8"))

View File

@@ -51,4 +51,4 @@
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }"))) "#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
(defcomp ~blog-editor-scripts (&key js-src init-js) (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 ;; Blog filter components
(defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label) (defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label)
(a :href href :hx-get href :hx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class icon-class) label)) :class btn-class :title title (i :class icon-class) label))
(defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count) (defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count)
(a :href href :hx-get href :hx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" :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 " :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))) (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) (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" (a :href href :sx-get href :sx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" :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 " :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))) (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) (defcomp ~blog-action-buttons-wrapper (&key inner)
(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner-html))) (div :class "flex flex-wrap gap-2 px-4 py-3" inner))
(defcomp ~blog-filter-any-topic (&key cls hx-select) (defcomp ~blog-filter-any-topic (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded border " cls) (li (a :class (str "px-3 py-1 rounded border " cls)
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select :sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" "Any Topic"))) :sx-swap "outerHTML" :sx-push-url "true" "Any Topic")))
(defcomp ~blog-filter-group-icon-image (&key src name) (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")) (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) (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)) (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) (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 :sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :sx-swap "outerHTML" :sx-push-url "true"
(raw! icon-html) 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 "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 "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)))) (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" (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) (defcomp ~blog-filter-any-author (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded " cls) (li (a :class (str "px-3 py-1 rounded " cls)
:hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select :sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" "Any author"))) :sx-swap "outerHTML" :sx-push-url "true" "Any author")))
(defcomp ~blog-filter-author-icon (&key src name) (defcomp ~blog-filter-author-icon (&key src name)
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover")) (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) (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 :sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :sx-swap "outerHTML" :sx-push-url "true"
(raw! icon-html) icon
(span :class "text-stone-700" name) (span :class "text-stone-700" name)
(span :class "flex-1") (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)))) (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,15 +3,19 @@
(defcomp ~blog-header-label () (defcomp ~blog-header-label ()
(div)) (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" (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 () (defcomp ~blog-admin-label ()
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")) (<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label) (defcomp ~blog-admin-nav-item (&key href nav-btn-class label is-selected select-colours)
(div :class "relative nav-group" (a :href href :class nav-btn-class label))) (div :class "relative nav-group"
(a :href href
:aria-selected (when is-selected "true")
:class (str (or nav-btn-class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3") " " (or select-colours ""))
label)))
(defcomp ~blog-sub-settings-label (&key icon label) (defcomp ~blog-sub-settings-label (&key icon label)
(<> (i :class icon :aria-hidden "true") " " label)) (<> (i :class icon :aria-hidden "true") " " label))

View File

@@ -5,8 +5,8 @@
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript) (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" (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" :sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:hx-swap "outerHTML" :_ hyperscript :sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true" :role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-8" (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")) (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) (defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none" (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" :sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:hx-swap "outerHTML" :_ hyperscript :sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true" :role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-2" (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")) (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) (defcomp ~blog-page-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none" (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 () (defcomp ~blog-no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found.")) (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" (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"))) :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" (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 (a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view" :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" (raw! list-svg-html)) :_ "on click js localStorage.removeItem('blog_view') end" list-svg)
(a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select (a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view" :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" (raw! tile-svg-html)))) :_ "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) (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" (div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :hx-get posts-href :hx-target "#main-panel" (a :href posts-href :sx-get posts-href :sx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" :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") :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" (a :href pages-href :sx-get pages-href :sx-target "#main-panel"
:hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" :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"))) :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) (defcomp ~blog-main-panel-pages (&key tabs cards)
(<> (raw! tabs-html) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards-html)) (div :class "pb-8"))) (<> 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) (defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards)
(<> (raw! tabs-html) (raw! toggle-html) (div :class grid-cls (raw! cards-html)) (div :class "pb-8"))) (<> 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) (defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter)
(<> (raw! search-html) (raw! action-buttons-html) (<> search
action-buttons
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" (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"))) (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))

View File

@@ -1,32 +1,32 @@
;; Blog navigation components ;; Blog navigation components
(defcomp ~blog-nav-empty (&key wrapper-id) (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) (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") (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"))) (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) (defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
(div (a :href href :hx-get hx-get :hx-target "#main-panel" (div (a :href href :sx-get hx-get :sx-target "#main-panel"
:hx-swap "outerHTML" :hx-push-url "true" :sx-swap "outerHTML" :sx-push-url "true"
:aria-selected selected :class nav-cls :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 (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" (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") (button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll left" :aria-label "Scroll left"
:_ left-hs (i :class "fa fa-chevron-left")) :_ left-hs (i :class "fa fa-chevron-left"))
(div :id container-id (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" :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 :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; }") (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") (button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
:aria-label "Scroll right" :aria-label "Scroll right"
@@ -35,7 +35,7 @@
;; Nav entries ;; Nav entries
(defcomp ~blog-nav-entries-empty () (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) (defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
(a :href href :class nav-cls (a :href href :class nav-cls
@@ -49,9 +49,9 @@
(i :class "fa fa-calendar" :aria-hidden "true") (i :class "fa fa-calendar" :aria-hidden "true")
(div name))) (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" (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" (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll left" :aria-label "Scroll left"
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200" :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
@@ -59,7 +59,7 @@
(div :id "associated-items-container" (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" :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 :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; }") (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" (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
:aria-label "Scroll right" :aria-label "Scroll right"

View File

@@ -1,8 +1,8 @@
;; Blog settings panel components (features, markets, associated entries) ;; Blog settings panel components (features, markets, associated entries)
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger) (defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
(form :hx-put features-url :hx-target "#features-panel" :hx-swap "outerHTML" (form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
:hx-headers "{\"Content-Type\": \"application/json\"}" :hx-ext "json-enc" :class "space-y-3" :sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3"
(label :class "flex items-center gap-3 cursor-pointer" (label :class "flex items-center gap-3 cursor-pointer"
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked (input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500" :class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
@@ -24,32 +24,32 @@
(defcomp ~blog-sumup-key-hint () (defcomp ~blog-sumup-key-hint ()
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")) (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" (div :class "mt-4 pt-4 border-t border-stone-100"
(h4 :class "text-sm font-medium text-stone-700" (h4 :class "text-sm font-medium text-stone-700"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment") (i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400 mt-1 mb-3" (p :class "text-xs text-stone-400 mt-1 mb-3"
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.") "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") (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" (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")) :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") (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder (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") :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") (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-" (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")) :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" (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" :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") "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" (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") (h3 :class "text-lg font-semibold text-stone-800" "Page Features")
(raw! form-html) (raw! sumup-html))) form sumup))
;; Markets panel ;; Markets panel
@@ -57,20 +57,20 @@
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded" (li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
(div (span :class "font-medium" name) (div (span :class "font-medium" name)
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/"))) (span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
(button :hx-delete delete-url :hx-target "#markets-panel" :hx-swap "outerHTML" (button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML"
:hx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete"))) :sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
(defcomp ~blog-markets-list (&key items-html) (defcomp ~blog-markets-list (&key items)
(ul :class "space-y-2 mb-4" (raw! items-html))) (ul :class "space-y-2 mb-4" items))
(defcomp ~blog-markets-empty () (defcomp ~blog-markets-empty ()
(p :class "text-stone-500 mb-4 text-sm" "No markets yet.")) (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" (div :id "markets-panel"
(h3 :class "text-lg font-semibold mb-3" "Markets") (h3 :class "text-lg font-semibold mb-3" "Markets")
(raw! list-html) list
(form :hx-post create-url :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2" (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 "" (input :type "text" :name "name" :placeholder "Market name" :required ""
:class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm") :class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")
(button :type "submit" (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") (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"))) (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" (button :type "button"
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100" :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 "" :data-confirm-title "Remove entry?"
:data-confirm-text confirm-text :data-confirm-icon "warning" :data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, remove it" :data-confirm-confirm-text "Yes, remove it"
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed" :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
:hx-post toggle-url :hx-trigger "confirmed" :sx-post toggle-url :sx-trigger "confirmed"
:hx-target "#associated-entries-list" :hx-swap "outerHTML" :sx-target "#associated-entries-list" :sx-swap "outerHTML"
:hx-headers hx-headers :sx-headers hx-headers
:_ "on htmx:afterRequest trigger entryToggled on body" :_ "on htmx:afterRequest trigger entryToggled on body"
(div :class "flex items-center justify-between gap-3" (div :class "flex items-center justify-between gap-3"
(raw! img-html) img
(div :class "flex-1" (div :class "flex-1"
(div :class "font-medium text-sm" name) (div :class "font-medium text-sm" name)
(div :class "text-xs text-stone-600 mt-1" date-str)) (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")))) (i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
(defcomp ~blog-associated-entries-content (&key items-html) (defcomp ~blog-associated-entries-content (&key items)
(div :class "space-y-1" (raw! items-html))) (div :class "space-y-1" items))
(defcomp ~blog-associated-entries-empty () (defcomp ~blog-associated-entries-empty ()
(div :class "text-sm text-stone-400" (div :class "text-sm text-stone-400"
"No entries associated yet. Browse calendars below to add entries.")) "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" (div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries") (h3 :class "text-lg font-semibold mb-4" "Associated Entries")
(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 %} {% set new_href = url_for('blog.new_post')|host %}
<a <a
href="{{ new_href }}" href="{{ new_href }}"
hx-get="{{ new_href }}" sx-get="{{ new_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors" class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="New Post" title="New Post"
> >
@@ -17,11 +17,11 @@
{% set new_page_href = url_for('blog.new_page')|host %} {% set new_page_href = url_for('blog.new_page')|host %}
<a <a
href="{{ new_page_href }}" href="{{ new_page_href }}"
hx-get="{{ new_page_href }}" sx-get="{{ new_page_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors" class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
title="New Page" title="New Page"
> >
@@ -33,11 +33,11 @@
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %} {% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
<a <a
href="{{ drafts_off_href }}" href="{{ drafts_off_href }}"
hx-get="{{ drafts_off_href }}" sx-get="{{ drafts_off_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors" class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="Hide Drafts" title="Hide Drafts"
> >
@@ -48,11 +48,11 @@
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %} {% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
<a <a
href="{{ drafts_on_href }}" href="{{ drafts_on_href }}"
hx-get="{{ drafts_on_href }}" sx-get="{{ drafts_on_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors" class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
title="Show Drafts" title="Show Drafts"
> >

View File

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

View File

@@ -11,43 +11,11 @@
<div <div
id="sentinel-{{ page }}-m" id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel" class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}" sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry" sx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML" sx-swap="outerHTML"
_=" sx-media="(max-width: 767px)"
init sx-retry="exponential:1000:30000"
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()
"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-hidden="true" aria-hidden="true"
@@ -58,47 +26,10 @@
<div <div
id="sentinel-{{ page }}-d" id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none" class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}" sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry" sx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML" sx-swap="outerHTML"
_=" sx-retry="exponential:1000:30000"
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()
"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-hidden="true" aria-hidden="true"

View File

@@ -5,21 +5,21 @@
{% set pages_href = (url_for('blog.index') ~ '?type=pages')|host %} {% set pages_href = (url_for('blog.index') ~ '?type=pages')|host %}
<a <a
href="{{ posts_href }}" href="{{ posts_href }}"
hx-get="{{ posts_href }}" sx-get="{{ posts_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{ hx_select_search }}" sx-select="{{ hx_select_search }}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors 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' }}" {{ 'bg-stone-700 text-white' if content_type != 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
>Posts</a> >Posts</a>
<a <a
href="{{ pages_href }}" href="{{ pages_href }}"
hx-get="{{ pages_href }}" sx-get="{{ pages_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{ hx_select_search }}" sx-select="{{ hx_select_search }}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors 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' }}" {{ 'bg-stone-700 text-white' if content_type == 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
>Pages</a> >Pages</a>
@@ -40,14 +40,14 @@
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %} {% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a <a
href="{{ list_href }}" href="{{ list_href }}"
hx-get="{{ list_href }}" sx-get="{{ list_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" 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' }}" 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" 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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
@@ -55,14 +55,14 @@
</a> </a>
<a <a
href="{{ tile_href }}" href="{{ tile_href }}"
hx-get="{{ tile_href }}" sx-get="{{ tile_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" 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' }}" 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" 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"> <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" /> <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 %} {% set _href = url_for('blog.post.post_detail', slug=page.slug)|host %}
<a <a
href="{{ _href }}" href="{{ _href }}"
hx-get="{{ _href }}" sx-get="{{ _href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{ hx_select_search }}" sx-select="{{ hx_select_search }}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden" class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
> >
<header class="mb-2 text-center"> <header class="mb-2 text-center">

View File

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

View File

@@ -13,11 +13,11 @@
<a <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 %}" 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 }}" href="{{ href }}"
hx-get="{{ href }}" sx-get="{{ href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
> >
Any author Any author
</a> </a>
@@ -32,11 +32,11 @@
<a <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 %}" 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 }}" href="{{ href }}"
hx-get="{{ href }}" sx-get="{{ href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
> >
{{doauthor.author(author)}} {{doauthor.author(author)}}

View File

@@ -11,11 +11,11 @@
<a <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 %}" 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 }}" href="{{ href }}"
hx-get="{{ href }}" sx-get="{{ href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
> >
Any Topic Any Topic
</a> </a>
@@ -31,11 +31,11 @@
<a <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 %}" 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 }}" href="{{ href }}"
hx-get="{{ href }}" sx-get="{{ href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
> >
{% if group.feature_image %} {% if group.feature_image %}

View File

@@ -12,11 +12,11 @@
<a <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 %}" 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 }}" href="{{ href }}"
hx-get="{{ href }}" sx-get="{{ href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
> >
Any Tag Any Tag
</a> </a>
@@ -31,11 +31,11 @@
<a <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 %}" 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 }}" href="{{ href }}"
hx-get="{{ href }}" sx-get="{{ href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
> >
{{dotag.tag(tag)}} {{dotag.tag(tag)}}

View File

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

View File

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

View File

@@ -38,14 +38,14 @@
{# Form for submission #} {# Form for submission #}
<form <form
{% if menu_item %} {% 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 %} {% else %}
hx-post="{{ url_for('menu_items.create_menu_item_route') }}" sx-post="{{ url_for('menu_items.create_menu_item_route') }}"
{% endif %} {% endif %}
hx-target="#menu-items-list" sx-target="#menu-items-list"
hx-swap="innerHTML" sx-swap="innerHTML"
hx-include="#selected-post-id" sx-include="#selected-post-id"
hx-on::after-request="if(event.detail.successful) { document.getElementById('menu-item-form').innerHTML = '' }" sx-on:afterRequest="if(event.detail.successful) { document.getElementById('menu-item-form').innerHTML = '' }"
class="space-y-4"> class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Form actions #} {# Form actions #}
@@ -74,10 +74,10 @@
<input <input
type="text" type="text"
placeholder="Search for a page... (or leave blank for all)" placeholder="Search for a page... (or leave blank for all)"
hx-get="{{ url_for('menu_items.search_pages_route') }}" sx-get="{{ url_for('menu_items.search_pages_route') }}"
hx-trigger="keyup changed delay:300ms, focus once" sx-trigger="keyup changed delay:300ms, focus once"
hx-target="#page-search-results" sx-target="#page-search-results"
hx-swap="innerHTML" sx-swap="innerHTML"
name="q" name="q"
id="page-search-input" 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" /> 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"> <div class="flex gap-2 flex-shrink-0">
<button <button
type="button" type="button"
hx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}" sx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
hx-target="#menu-item-form" sx-target="#menu-item-form"
hx-swap="innerHTML" sx-swap="innerHTML"
class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"> class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded">
<i class="fa fa-edit"></i> Edit <i class="fa fa-edit"></i> Edit
</button> </button>
@@ -47,11 +47,11 @@
data-confirm-confirm-text="Yes, delete" data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel" data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed" data-confirm-event="confirmed"
hx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}" sx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}"
hx-trigger="confirmed" sx-trigger="confirmed"
hx-target="#menu-items-list" sx-target="#menu-items-list"
hx-swap="innerHTML" sx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"> 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 <i class="fa fa-trash"></i> Delete
</button> </button>

View File

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

View File

@@ -2,18 +2,18 @@
{% set _first_seg = request.path.strip('/').split('/')[0] %} {% 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" <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" id="menu-items-nav-wrapper"
hx-swap-oob="outerHTML"> sx-swap-oob="outerHTML">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(item) scrolling_menu('menu-items-container', menu_items) %} {% call(item) scrolling_menu('menu-items-container', menu_items) %}
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %} {% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
<a <a
href="{{ _href }}" href="{{ _href }}"
{% if item.slug not in _app_slugs %} {% if item.slug not in _app_slugs %}
hx-get="/{{ item.slug }}/" sx-get="/{{ item.slug }}/"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{ hx_select_search }}" sx-select="{{ hx_select_search }}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" sx-push-url="true"
{% endif %} {% endif %}
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}" aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
class="{{styles.nav_button}}" class="{{styles.nav_button}}"

View File

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

View File

@@ -1,13 +1,7 @@
<div id="associated-entries-container" <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" 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;" style="scroll-behavior: smooth;"
_="on load or scroll data-scroll-arrows="entries-nav-arrow">
-- 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">
<div class="flex flex-col sm:flex-row gap-1"> <div class="flex flex-col sm:flex-row gap-1">
{% include '_types/post/_entry_items.html' with context %} {% include '_types/post/_entry_items.html' with context %}
</div> </div>

View File

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

View File

@@ -12,11 +12,11 @@
{% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %} {% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
<a <a
href="{{ edit_href }}" href="{{ edit_href }}"
hx-get="{{ edit_href }}" sx-get="{{ edit_href }}"
hx-target="#main-panel" sx-target="#main-panel"
hx-select="{{hx_select_search}}" sx-select="{{hx_select_search}}"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-push-url="true" 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" 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 <i class="fa fa-pencil mr-1"></i> Edit

View File

@@ -15,12 +15,12 @@
data-confirm-confirm-text="Yes, remove it" data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel" data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed" data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}" sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}"
hx-trigger="confirmed" sx-trigger="confirmed"
hx-target="#associated-entries-list" sx-target="#associated-entries-list"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body" sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))"
> >
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
{% if calendar.post.feature_image %} {% if calendar.post.feature_image %}

View File

@@ -1,15 +1,15 @@
<div id="calendar-view-{{ calendar.id }}" <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) }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}"
hx-trigger="entryToggled from:body" sx-trigger="entryToggled from:body"
hx-swap="outerHTML"> sx-swap="outerHTML">
{# Month/year navigation #} {# Month/year navigation #}
<header class="flex items-center justify-center mb-4"> <header class="flex items-center justify-center mb-4">
<nav class="flex items-center gap-2 text-xl"> <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_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 }}" 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_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> <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_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 }}" 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_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> </nav>
</header> </header>
@@ -45,12 +45,12 @@
data-confirm-confirm-text="Yes, remove it" data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel" data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed" data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed" sx-trigger="confirmed"
hx-target="#associated-entries-list" sx-target="#associated-entries-list"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body" sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))"
> >
<i class="fa fa-times"></i> <i class="fa fa-times"></i>
</button> </button>
@@ -67,12 +67,12 @@
data-confirm-confirm-text="Yes, add it" data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel" data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed" data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}" sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed" sx-trigger="confirmed"
hx-target="#associated-entries-list" sx-target="#associated-entries-list"
hx-swap="outerHTML" sx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body" sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))"
> >
<span class="truncate block">{{ e.name }}</span> <span class="truncate block">{{ e.name }}</span>
</button> </button>

View File

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

View File

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

View File

@@ -3,8 +3,7 @@
<button <button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left" aria-label="Scroll left"
_="on click onclick="document.getElementById('associated-items-container').scrollLeft -= 200">
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
<i class="fa fa-chevron-left"></i> <i class="fa fa-chevron-left"></i>
</button> </button>
@@ -12,15 +11,8 @@
<div id="associated-items-container" <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" 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;" style="scroll-behavior: smooth;"
_="on load or scroll data-scroll-arrows="entries-nav-arrow"
-- Show arrows if content overflows (desktop only) 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)">
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">
<div class="flex flex-col sm:flex-row gap-1"> <div class="flex flex-col sm:flex-row gap-1">
{% for wdata in container_nav_widgets %} {% for wdata in container_nav_widgets %}
{% with ctx=wdata.ctx %} {% with ctx=wdata.ctx %}
@@ -44,7 +36,6 @@
<button <button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right" aria-label="Scroll right"
_="on click onclick="document.getElementById('associated-items-container').scrollLeft += 200">
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
<i class="fa fa-chevron-right"></i> <i class="fa fa-chevron-right"></i>
</button> </button>

View File

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

View File

@@ -1,10 +1,10 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6"> <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"> <div class="flex flex-col md:flex-row gap-3 items-start">
<form <form
hx-post="{{ url_for('settings.cache_clear') }}" sx-post="{{ url_for('settings.cache_clear') }}"
hx-trigger="submit" sx-trigger="submit"
hx-target="#cache-status" sx-target="#cache-status"
hx-swap="innerHTML" sx-swap="innerHTML"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <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> <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 %} {% if is_admin %}
<select <select
name="visibility" name="visibility"
hx-patch="{{ url_for('snippets.patch_visibility', snippet_id=s.id) }}" sx-patch="{{ url_for('snippets.patch_visibility', snippet_id=s.id) }}"
hx-target="#snippets-list" sx-target="#snippets-list"
hx-swap="innerHTML" sx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="text-sm border border-stone-300 rounded px-2 py-1" class="text-sm border border-stone-300 rounded px-2 py-1"
> >
{% for v in ['private', 'shared', 'admin'] %} {% for v in ['private', 'shared', 'admin'] %}
@@ -52,11 +52,11 @@
data-confirm-confirm-text="Yes, delete" data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel" data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed" data-confirm-event="confirmed"
hx-delete="{{ url_for('snippets.delete_snippet', snippet_id=s.id) }}" sx-delete="{{ url_for('snippets.delete_snippet', snippet_id=s.id) }}"
hx-trigger="confirmed" sx-trigger="confirmed"
hx-target="#snippets-list" sx-target="#snippets-list"
hx-swap="innerHTML" sx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}' 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"> 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 <i class="fa fa-trash"></i> Delete
</button> </button>

View File

@@ -19,8 +19,7 @@
<button <button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left" aria-label="Scroll left"
_="on click onclick="document.getElementById('{{ container_id }}').scrollLeft -= 200">
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
<i class="fa fa-chevron-left"></i> <i class="fa fa-chevron-left"></i>
</button> </button>
@@ -28,15 +27,8 @@
<div id="{{ container_id }}" <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 }}" 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;" style="scroll-behavior: smooth;"
_="on load or scroll data-scroll-arrows="scrolling-menu-arrow-{{ container_id }}"
-- Show arrows if content overflows (desktop only) 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)">
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">
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}"> <div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
{% for item in items %} {% for item in items %}
<div class="{{ item_class }}"> <div class="{{ item_class }}">
@@ -60,8 +52,7 @@
<button <button
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right" aria-label="Scroll right"
_="on click onclick="document.getElementById('{{ container_id }}').scrollLeft += 200">
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
<i class="fa fa-chevron-right"></i> <i class="fa fa-chevron-right"></i>
</button> </button>
{% endif %} {% endif %}

View File

@@ -0,0 +1,44 @@
"""Unit tests for card fragment parser."""
from __future__ import annotations
import pytest
from blog.bp.blog.services.posts_data import _parse_card_fragments
class TestParseCardFragments:
def test_empty_string(self):
assert _parse_card_fragments("") == {}
def test_single_block(self):
html = '<!-- card-widget:42 --><div>card</div><!-- /card-widget:42 -->'
result = _parse_card_fragments(html)
assert result == {"42": "<div>card</div>"}
def test_multiple_blocks(self):
html = (
'<!-- card-widget:1 -->A<!-- /card-widget:1 -->'
'<!-- card-widget:2 -->B<!-- /card-widget:2 -->'
)
result = _parse_card_fragments(html)
assert result == {"1": "A", "2": "B"}
def test_empty_inner_skipped(self):
html = '<!-- card-widget:99 --> <!-- /card-widget:99 -->'
result = _parse_card_fragments(html)
assert result == {}
def test_multiline_content(self):
html = '<!-- card-widget:5 -->\n<p>line1</p>\n<p>line2</p>\n<!-- /card-widget:5 -->'
result = _parse_card_fragments(html)
assert "5" in result
assert "<p>line1</p>" in result["5"]
def test_mismatched_ids_not_captured(self):
html = '<!-- card-widget:1 -->content<!-- /card-widget:2 -->'
result = _parse_card_fragments(html)
assert result == {}
def test_no_markers(self):
html = '<div>no markers here</div>'
assert _parse_card_fragments(html) == {}

View File

@@ -0,0 +1,103 @@
"""Unit tests for Ghost sync helper functions."""
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from blog.bp.blog.ghost.ghost_sync import _iso, _build_ap_post_data
class TestIso:
def test_none(self):
assert _iso(None) is None
def test_empty_string(self):
assert _iso("") is None
def test_z_suffix(self):
result = _iso("2024-01-15T10:30:00Z")
assert isinstance(result, datetime)
assert result.tzinfo is not None
assert result.year == 2024
assert result.month == 1
assert result.hour == 10
def test_offset_suffix(self):
result = _iso("2024-06-01T08:00:00+00:00")
assert isinstance(result, datetime)
assert result.hour == 8
class TestBuildApPostData:
def _post(self, **kwargs):
defaults = {
"title": "My Post",
"plaintext": "Some body text.",
"custom_excerpt": None,
"excerpt": None,
"feature_image": None,
"feature_image_alt": None,
"html": None,
}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
def _tag(self, slug):
return SimpleNamespace(slug=slug)
def test_basic_post(self):
post = self._post()
result = _build_ap_post_data(post, "https://blog.example.com/post/", [])
assert result["name"] == "My Post"
assert "My Post" in result["content"]
assert "Some body text." in result["content"]
assert result["url"] == "https://blog.example.com/post/"
def test_no_title(self):
post = self._post(title=None)
result = _build_ap_post_data(post, "https://example.com/", [])
assert result["name"] == ""
def test_feature_image(self):
post = self._post(feature_image="https://img.com/photo.jpg",
feature_image_alt="A photo")
result = _build_ap_post_data(post, "https://example.com/", [])
assert "attachment" in result
assert result["attachment"][0]["url"] == "https://img.com/photo.jpg"
assert result["attachment"][0]["name"] == "A photo"
def test_inline_images_capped_at_4(self):
html = "".join(f'<img src="https://img.com/{i}.jpg">' for i in range(10))
post = self._post(html=html)
result = _build_ap_post_data(post, "https://example.com/", [])
assert len(result["attachment"]) == 4
def test_tags(self):
tags = [self._tag("my-tag"), self._tag("another")]
post = self._post()
result = _build_ap_post_data(post, "https://example.com/", tags)
assert "tag" in result
assert len(result["tag"]) == 2
assert result["tag"][0]["name"] == "#mytag" # dashes removed
assert result["tag"][0]["type"] == "Hashtag"
def test_hashtag_in_content(self):
tags = [self._tag("web-dev")]
post = self._post()
result = _build_ap_post_data(post, "https://example.com/", tags)
assert "#webdev" in result["content"]
def test_no_duplicate_images(self):
post = self._post(
feature_image="https://img.com/same.jpg",
html='<img src="https://img.com/same.jpg">',
)
result = _build_ap_post_data(post, "https://example.com/", [])
assert len(result["attachment"]) == 1
def test_multiline_body(self):
post = self._post(plaintext="Para one.\n\nPara two.\n\nPara three.")
result = _build_ap_post_data(post, "https://example.com/", [])
assert result["content"].count("<p>") >= 4 # title + 3 paras + read more

View File

@@ -0,0 +1,393 @@
"""Unit tests for the Lexical JSON → HTML renderer."""
from __future__ import annotations
import pytest
from blog.bp.blog.ghost.lexical_renderer import (
render_lexical, _wrap_format, _align_style,
_FORMAT_BOLD, _FORMAT_ITALIC, _FORMAT_STRIKETHROUGH,
_FORMAT_UNDERLINE, _FORMAT_CODE, _FORMAT_SUBSCRIPT,
_FORMAT_SUPERSCRIPT, _FORMAT_HIGHLIGHT,
)
# ---------------------------------------------------------------------------
# _wrap_format
# ---------------------------------------------------------------------------
class TestWrapFormat:
def test_no_format(self):
assert _wrap_format("hello", 0) == "hello"
def test_bold(self):
assert _wrap_format("x", _FORMAT_BOLD) == "<strong>x</strong>"
def test_italic(self):
assert _wrap_format("x", _FORMAT_ITALIC) == "<em>x</em>"
def test_strikethrough(self):
assert _wrap_format("x", _FORMAT_STRIKETHROUGH) == "<s>x</s>"
def test_underline(self):
assert _wrap_format("x", _FORMAT_UNDERLINE) == "<u>x</u>"
def test_code(self):
assert _wrap_format("x", _FORMAT_CODE) == "<code>x</code>"
def test_subscript(self):
assert _wrap_format("x", _FORMAT_SUBSCRIPT) == "<sub>x</sub>"
def test_superscript(self):
assert _wrap_format("x", _FORMAT_SUPERSCRIPT) == "<sup>x</sup>"
def test_highlight(self):
assert _wrap_format("x", _FORMAT_HIGHLIGHT) == "<mark>x</mark>"
def test_bold_italic(self):
result = _wrap_format("x", _FORMAT_BOLD | _FORMAT_ITALIC)
assert "<strong>" in result
assert "<em>" in result
def test_all_flags(self):
all_flags = (
_FORMAT_BOLD | _FORMAT_ITALIC | _FORMAT_STRIKETHROUGH |
_FORMAT_UNDERLINE | _FORMAT_CODE | _FORMAT_SUBSCRIPT |
_FORMAT_SUPERSCRIPT | _FORMAT_HIGHLIGHT
)
result = _wrap_format("x", all_flags)
for tag in ["strong", "em", "s", "u", "code", "sub", "sup", "mark"]:
assert f"<{tag}>" in result
assert f"</{tag}>" in result
# ---------------------------------------------------------------------------
# _align_style
# ---------------------------------------------------------------------------
class TestAlignStyle:
def test_no_format(self):
assert _align_style({}) == ""
def test_format_zero(self):
assert _align_style({"format": 0}) == ""
def test_left(self):
assert _align_style({"format": 1}) == ' style="text-align: left"'
def test_center(self):
assert _align_style({"format": 2}) == ' style="text-align: center"'
def test_right(self):
assert _align_style({"format": 3}) == ' style="text-align: right"'
def test_justify(self):
assert _align_style({"format": 4}) == ' style="text-align: justify"'
def test_string_format(self):
assert _align_style({"format": "center"}) == ' style="text-align: center"'
def test_unmapped_int(self):
assert _align_style({"format": 99}) == ""
# ---------------------------------------------------------------------------
# render_lexical — text nodes
# ---------------------------------------------------------------------------
class TestRenderLexicalText:
def test_empty_doc(self):
assert render_lexical({"root": {"children": []}}) == ""
def test_plain_text(self):
doc = {"root": {"children": [
{"type": "text", "text": "hello"}
]}}
assert render_lexical(doc) == "hello"
def test_html_escape(self):
doc = {"root": {"children": [
{"type": "text", "text": "<script>alert('xss')</script>"}
]}}
result = render_lexical(doc)
assert "<script>" not in result
assert "&lt;script&gt;" in result
def test_bold_text(self):
doc = {"root": {"children": [
{"type": "text", "text": "bold", "format": _FORMAT_BOLD}
]}}
assert render_lexical(doc) == "<strong>bold</strong>"
def test_string_input(self):
import json
doc = {"root": {"children": [{"type": "text", "text": "hi"}]}}
assert render_lexical(json.dumps(doc)) == "hi"
# ---------------------------------------------------------------------------
# render_lexical — block nodes
# ---------------------------------------------------------------------------
class TestRenderLexicalBlocks:
def test_paragraph(self):
doc = {"root": {"children": [
{"type": "paragraph", "children": [
{"type": "text", "text": "hello"}
]}
]}}
assert render_lexical(doc) == "<p>hello</p>"
def test_empty_paragraph(self):
doc = {"root": {"children": [
{"type": "paragraph", "children": []}
]}}
assert render_lexical(doc) == "<p><br></p>"
def test_heading_default(self):
doc = {"root": {"children": [
{"type": "heading", "children": [
{"type": "text", "text": "title"}
]}
]}}
assert render_lexical(doc) == "<h2>title</h2>"
def test_heading_h3(self):
doc = {"root": {"children": [
{"type": "heading", "tag": "h3", "children": [
{"type": "text", "text": "title"}
]}
]}}
assert render_lexical(doc) == "<h3>title</h3>"
def test_blockquote(self):
doc = {"root": {"children": [
{"type": "quote", "children": [
{"type": "text", "text": "quoted"}
]}
]}}
assert render_lexical(doc) == "<blockquote>quoted</blockquote>"
def test_linebreak(self):
doc = {"root": {"children": [{"type": "linebreak"}]}}
assert render_lexical(doc) == "<br>"
def test_horizontal_rule(self):
doc = {"root": {"children": [{"type": "horizontalrule"}]}}
assert render_lexical(doc) == "<hr>"
def test_unordered_list(self):
doc = {"root": {"children": [
{"type": "list", "listType": "bullet", "children": [
{"type": "listitem", "children": [
{"type": "text", "text": "item"}
]}
]}
]}}
assert render_lexical(doc) == "<ul><li>item</li></ul>"
def test_ordered_list(self):
doc = {"root": {"children": [
{"type": "list", "listType": "number", "children": [
{"type": "listitem", "children": [
{"type": "text", "text": "one"}
]}
]}
]}}
assert render_lexical(doc) == "<ol><li>one</li></ol>"
def test_ordered_list_custom_start(self):
doc = {"root": {"children": [
{"type": "list", "listType": "number", "start": 5, "children": []}
]}}
result = render_lexical(doc)
assert 'start="5"' in result
def test_link(self):
doc = {"root": {"children": [
{"type": "link", "url": "https://example.com", "children": [
{"type": "text", "text": "click"}
]}
]}}
result = render_lexical(doc)
assert 'href="https://example.com"' in result
assert "click" in result
def test_link_xss_url(self):
doc = {"root": {"children": [
{"type": "link", "url": 'javascript:alert("xss")', "children": []}
]}}
result = render_lexical(doc)
assert "javascript:alert(&quot;xss&quot;)" in result
# ---------------------------------------------------------------------------
# render_lexical — cards
# ---------------------------------------------------------------------------
class TestRenderLexicalCards:
def test_image(self):
doc = {"root": {"children": [
{"type": "image", "src": "photo.jpg", "alt": "A photo"}
]}}
result = render_lexical(doc)
assert "kg-image-card" in result
assert 'src="photo.jpg"' in result
assert 'alt="A photo"' in result
assert 'loading="lazy"' in result
def test_image_wide(self):
doc = {"root": {"children": [
{"type": "image", "src": "x.jpg", "cardWidth": "wide"}
]}}
assert "kg-width-wide" in render_lexical(doc)
def test_image_with_caption(self):
doc = {"root": {"children": [
{"type": "image", "src": "x.jpg", "caption": "Caption text"}
]}}
assert "<figcaption>Caption text</figcaption>" in render_lexical(doc)
def test_codeblock(self):
doc = {"root": {"children": [
{"type": "codeblock", "code": "print('hi')", "language": "python"}
]}}
result = render_lexical(doc)
assert 'class="language-python"' in result
assert "print(&#x27;hi&#x27;)" in result
def test_html_card(self):
doc = {"root": {"children": [
{"type": "html", "html": "<div>raw</div>"}
]}}
result = render_lexical(doc)
assert "<!--kg-card-begin: html-->" in result
assert "<div>raw</div>" in result
def test_markdown_card(self):
doc = {"root": {"children": [
{"type": "markdown", "markdown": "**bold**"}
]}}
result = render_lexical(doc)
assert "<!--kg-card-begin: markdown-->" in result
assert "<strong>bold</strong>" in result
def test_callout(self):
doc = {"root": {"children": [
{"type": "callout", "backgroundColor": "blue",
"calloutEmoji": "💡", "children": [
{"type": "text", "text": "Note"}
]}
]}}
result = render_lexical(doc)
assert "kg-callout-card-blue" in result
assert "💡" in result
def test_button(self):
doc = {"root": {"children": [
{"type": "button", "buttonText": "Click",
"buttonUrl": "https://example.com", "alignment": "left"}
]}}
result = render_lexical(doc)
assert "kg-align-left" in result
assert "Click" in result
def test_toggle(self):
doc = {"root": {"children": [
{"type": "toggle", "heading": "FAQ", "children": [
{"type": "text", "text": "Answer"}
]}
]}}
result = render_lexical(doc)
assert "kg-toggle-card" in result
assert "FAQ" in result
def test_audio_duration(self):
doc = {"root": {"children": [
{"type": "audio", "src": "a.mp3", "title": "Song", "duration": 185}
]}}
result = render_lexical(doc)
assert "3:05" in result
def test_audio_zero_duration(self):
doc = {"root": {"children": [
{"type": "audio", "src": "a.mp3", "duration": 0}
]}}
assert "0:00" in render_lexical(doc)
def test_video(self):
doc = {"root": {"children": [
{"type": "video", "src": "v.mp4", "loop": True}
]}}
result = render_lexical(doc)
assert "kg-video-card" in result
assert " loop" in result
def test_file_size_kb(self):
doc = {"root": {"children": [
{"type": "file", "src": "f.pdf", "fileName": "doc.pdf",
"fileSize": 512000} # 500 KB
]}}
assert "500 KB" in render_lexical(doc)
def test_file_size_mb(self):
doc = {"root": {"children": [
{"type": "file", "src": "f.zip", "fileName": "big.zip",
"fileSize": 5242880} # 5 MB
]}}
assert "5.0 MB" in render_lexical(doc)
def test_paywall(self):
doc = {"root": {"children": [{"type": "paywall"}]}}
assert render_lexical(doc) == "<!--members-only-->"
def test_embed(self):
doc = {"root": {"children": [
{"type": "embed", "html": "<iframe></iframe>",
"caption": "Video"}
]}}
result = render_lexical(doc)
assert "kg-embed-card" in result
assert "<figcaption>Video</figcaption>" in result
def test_bookmark(self):
doc = {"root": {"children": [
{"type": "bookmark", "url": "https://example.com",
"metadata": {"title": "Example", "description": "A site"}}
]}}
result = render_lexical(doc)
assert "kg-bookmark-card" in result
assert "Example" in result
def test_unknown_node_ignored(self):
doc = {"root": {"children": [
{"type": "unknown-future-thing"}
]}}
assert render_lexical(doc) == ""
def test_product_stars(self):
doc = {"root": {"children": [
{"type": "product", "productTitle": "Widget",
"rating": 3, "productDescription": "Nice"}
]}}
result = render_lexical(doc)
assert "kg-product-card" in result
assert result.count("kg-product-card-rating-active") == 3
def test_header_card(self):
doc = {"root": {"children": [
{"type": "header", "heading": "Welcome",
"size": "large", "style": "dark"}
]}}
result = render_lexical(doc)
assert "kg-header-card" in result
assert "kg-size-large" in result
assert "Welcome" in result
def test_signup_card(self):
doc = {"root": {"children": [
{"type": "signup", "heading": "Subscribe",
"buttonText": "Join", "style": "light"}
]}}
result = render_lexical(doc)
assert "kg-signup-card" in result
assert "Join" in result

View File

@@ -0,0 +1,83 @@
"""Unit tests for lexical document validator."""
from __future__ import annotations
import pytest
from blog.bp.blog.ghost.lexical_validator import (
validate_lexical, ALLOWED_NODE_TYPES,
)
class TestValidateLexical:
def test_valid_empty_doc(self):
ok, reason = validate_lexical({"root": {"type": "root", "children": []}})
assert ok is True
assert reason is None
def test_non_dict_input(self):
ok, reason = validate_lexical("not a dict")
assert ok is False
assert "JSON object" in reason
def test_list_input(self):
ok, reason = validate_lexical([])
assert ok is False
def test_missing_root(self):
ok, reason = validate_lexical({"foo": "bar"})
assert ok is False
assert "'root'" in reason
def test_root_not_dict(self):
ok, reason = validate_lexical({"root": "string"})
assert ok is False
def test_valid_paragraph(self):
doc = {"root": {"type": "root", "children": [
{"type": "paragraph", "children": [
{"type": "text", "text": "hello"}
]}
]}}
ok, _ = validate_lexical(doc)
assert ok is True
def test_disallowed_type(self):
doc = {"root": {"type": "root", "children": [
{"type": "script"}
]}}
ok, reason = validate_lexical(doc)
assert ok is False
assert "Disallowed node type: script" in reason
def test_nested_disallowed_type(self):
doc = {"root": {"type": "root", "children": [
{"type": "paragraph", "children": [
{"type": "list", "children": [
{"type": "evil-widget"}
]}
]}
]}}
ok, reason = validate_lexical(doc)
assert ok is False
assert "evil-widget" in reason
def test_node_without_type_allowed(self):
"""Nodes with type=None are allowed by _walk."""
doc = {"root": {"type": "root", "children": [
{"children": []} # no "type" key
]}}
ok, _ = validate_lexical(doc)
assert ok is True
def test_all_allowed_types(self):
"""Every type in the allowlist should pass."""
for node_type in ALLOWED_NODE_TYPES:
doc = {"root": {"type": "root", "children": [
{"type": node_type, "children": []}
]}}
ok, reason = validate_lexical(doc)
assert ok is True, f"{node_type} should be allowed but got: {reason}"
def test_allowed_types_count(self):
"""Sanity: at least 30 types in the allowlist."""
assert len(ALLOWED_NODE_TYPES) >= 30

View File

@@ -0,0 +1,50 @@
"""Unit tests for blog slugify utility."""
from __future__ import annotations
import pytest
from blog.bp.post.services.markets import slugify
class TestSlugify:
def test_basic_ascii(self):
assert slugify("Hello World") == "hello-world"
def test_unicode_stripped(self):
assert slugify("café") == "cafe"
def test_slashes_to_dashes(self):
assert slugify("foo/bar") == "foo-bar"
def test_special_chars(self):
assert slugify("foo!!bar") == "foo-bar"
def test_multiple_dashes_collapsed(self):
assert slugify("foo---bar") == "foo-bar"
def test_leading_trailing_dashes_stripped(self):
assert slugify("--foo--") == "foo"
def test_empty_string_fallback(self):
assert slugify("") == "market"
def test_none_fallback(self):
assert slugify(None) == "market"
def test_max_len_truncation(self):
result = slugify("a" * 300, max_len=10)
assert len(result) <= 10
def test_truncation_no_trailing_dash(self):
# "abcde-fgh" truncated to 5 should not end with dash
result = slugify("abcde fgh", max_len=5)
assert not result.endswith("-")
def test_already_clean(self):
assert slugify("hello-world") == "hello-world"
def test_numbers_preserved(self):
assert slugify("item-42") == "item-42"
def test_accented_characters(self):
assert slugify("über straße") == "uber-strae"

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
import 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 decimal import Decimal
from pathlib import Path from pathlib import Path
@@ -50,7 +50,7 @@ async def cart_context() -> dict:
- cart / calendar_cart_entries / total / calendar_total: direct DB - cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data) (cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html) - 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. When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini. 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: if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"] 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), ("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None), ("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "cart", "path": request.path}), ("blog", "nav-tree", {"app_name": "cart", "path": request.path}),
]) ])
ctx["cart_mini_html"] = cart_mini_html ctx["cart_mini"] = cart_mini
ctx["auth_menu_html"] = auth_menu_html ctx["auth_menu"] = auth_menu
ctx["nav_tree_html"] = nav_tree_html ctx["nav_tree"] = nav_tree
# Cart app owns cart data — use g.cart from _load_cart # Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or [] all_cart = getattr(g, "cart", None) or []

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"""Cart app fragment endpoints. """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. by other coop apps via the fragment client.
Fragments: Fragments:
@@ -19,13 +19,13 @@ def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Fragment handlers # Fragment handlers — return sx source text
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def _cart_mini(): async def _cart_mini():
from shared.services.registry import services from shared.services.registry import services
from shared.infrastructure.urls import blog_url, cart_url 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) user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id") session_id = request.args.get("session_id")
@@ -35,19 +35,19 @@ def register():
) )
count = summary.count + summary.calendar_count + summary.ticket_count count = summary.count + summary.calendar_count + summary.ticket_count
oob = request.args.get("oob", "") oob = request.args.get("oob", "")
return render_sexp( return sx_call("cart-mini",
'(~cart-mini :cart-count cart-count :blog-url blog-url :cart-url cart-url :oob oob)', cart_count=count,
**{"cart-count": count, "blog-url": blog_url(""), "cart-url": cart_url(""), "oob": oob or None}, blog_url=blog_url(""),
) cart_url=cart_url(""),
oob=oob or None)
async def _account_nav_item(): async def _account_nav_item():
from shared.infrastructure.urls import cart_url 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( return sx_call("account-nav-item",
'(~account-nav-item :href href :label "orders")',
href=cart_url("/orders/"), href=cart_url("/orders/"),
) label="orders")
_handlers = { _handlers = {
"cart-mini": _cart_mini, "cart-mini": _cart_mini,
@@ -67,8 +67,8 @@ def register():
async def get_fragment(fragment_type: str): async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type) handler = _handlers.get(fragment_type)
if handler is None: if handler is None:
return Response("", status=200, content_type="text/html") return Response("", status=200, content_type="text/sx")
html = await handler() src = await handler()
return Response(html, status=200, content_type="text/html") return Response(src, status=200, content_type="text/sx")
return bp 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 shared.infrastructure.cart_identity import current_cart_identity
from bp.cart.services import check_sumup_status from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from .filters.qs import makeqs_factory, decode from .filters.qs import makeqs_factory, decode
@@ -55,18 +56,18 @@ def register() -> Blueprint:
order = result.scalar_one_or_none() order = result.scalar_one_or_none()
if not order: if not order:
return await make_response("Order not found", 404) return await make_response("Order not found", 404)
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_order_page, render_order_oob from sx.sx_components import render_order_page, render_order_oob
ctx = await get_template_context() ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries") calendar_entries = ctx.get("calendar_entries")
if not is_htmx_request(): if not is_htmx_request():
html = await render_order_page(ctx, order, calendar_entries, url_for) html = await render_order_page(ctx, order, calendar_entries, url_for)
else:
html = await render_order_oob(ctx, order, calendar_entries, url_for)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_order_oob(ctx, order, calendar_entries, url_for)
return sx_response(sx_src)
@bp.get("/pay/") @bp.get("/pay/")
async def order_pay(order_id: int): async def order_pay(order_id: int):
@@ -120,8 +121,8 @@ def register() -> Blueprint:
await g.s.flush() await g.s.flush()
if not hosted_url: if not hosted_url:
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_checkout_error_page from sx.sx_components import render_checkout_error_page
tctx = await get_template_context() tctx = await get_template_context()
html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order) html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order)
return await make_response(html, 500) return await make_response(html, 500)

View File

@@ -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 shared.infrastructure.cart_identity import current_cart_identity
from bp.cart.services import check_sumup_status from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
from bp import register_order from bp import register_order
from .filters.qs import makeqs_factory, decode from .filters.qs import makeqs_factory, decode
@@ -136,8 +137,8 @@ def register(url_prefix: str) -> Blueprint:
result = await g.s.execute(stmt) result = await g.s.execute(stmt)
orders = result.scalars().all() orders = result.scalars().all()
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import ( from sx.sx_components import (
render_orders_page, render_orders_page,
render_orders_rows, render_orders_rows,
render_orders_oob, render_orders_oob,
@@ -151,17 +152,18 @@ def register(url_prefix: str) -> Blueprint:
ctx, orders, page, total_pages, search, total_count, ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn, url_for, qs_fn,
) )
resp = await make_response(html)
elif page > 1: elif page > 1:
html = await render_orders_rows( sx_src = await render_orders_rows(
ctx, orders, page, total_pages, url_for, qs_fn, ctx, orders, page, total_pages, url_for, qs_fn,
) )
resp = sx_response(sx_src)
else: else:
html = await render_orders_oob( sx_src = await render_orders_oob(
ctx, orders, page, total_pages, search, total_count, ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn, url_for, qs_fn,
) )
resp = sx_response(sx_src)
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page() resp.headers["Hx-Push-Url"] = _current_url_without_page()
return _vary(resp) 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.infrastructure.data_client import fetch_data
from shared.browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register(): def register():
@@ -16,30 +17,32 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(**kwargs): async def admin(**kwargs):
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_cart_admin_page, render_cart_admin_oob from sx.sx_components import render_cart_admin_page, render_cart_admin_oob
ctx = await get_template_context() ctx = await get_template_context()
page_post = getattr(g, "page_post", None) page_post = getattr(g, "page_post", None)
if not is_htmx_request(): if not is_htmx_request():
html = await render_cart_admin_page(ctx, page_post) html = await render_cart_admin_page(ctx, page_post)
else:
html = await render_cart_admin_oob(ctx, page_post)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_cart_admin_oob(ctx, page_post)
return sx_response(sx_src)
@bp.get("/payments/") @bp.get("/payments/")
@require_admin @require_admin
async def payments(**kwargs): async def payments(**kwargs):
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_cart_payments_page, render_cart_payments_oob from sx.sx_components import render_cart_payments_page, render_cart_payments_oob
ctx = await get_template_context() ctx = await get_template_context()
page_post = getattr(g, "page_post", None) page_post = getattr(g, "page_post", None)
if not is_htmx_request(): if not is_htmx_request():
html = await render_cart_payments_page(ctx, page_post) html = await render_cart_payments_page(ctx, page_post)
else:
html = await render_cart_payments_oob(ctx, page_post)
return await make_response(html) return await make_response(html)
else:
sx_src = await render_cart_payments_oob(ctx, page_post)
return sx_response(sx_src)
@bp.put("/payments/") @bp.put("/payments/")
@require_admin @require_admin
@@ -74,10 +77,10 @@ def register():
) )
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sexp.page import get_template_context from shared.sx.page import get_template_context
from sexp.sexp_components import render_cart_payments_panel from sx.sx_components import render_cart_payments_panel
ctx = await get_template_context() ctx = await get_template_context()
html = render_cart_payments_panel(ctx) html = render_cart_payments_panel(ctx)
return await make_response(html) return sx_response(html)
return bp return bp

View File

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

View File

@@ -2,11 +2,11 @@
(defcomp ~cart-cal-entry (&key name date-str cost) (defcomp ~cart-cal-entry (&key name date-str cost)
(li :class "flex items-start justify-between text-sm" (li :class "flex items-start justify-between text-sm"
(div (div :class "font-medium" (raw! name)) (div (div :class "font-medium" name)
(div :class "text-xs text-stone-500" (raw! date-str))) (div :class "text-xs text-stone-500" date-str))
(div :class "ml-4 font-medium" (raw! cost)))) (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" (div :class "mt-6 border-t border-stone-200 pt-4"
(h2 :class "text-base font-semibold mb-2" "Calendar bookings") (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) (defcomp ~cart-checkout-error-order-id (&key order-id)
(p :class "text-xs text-rose-800/80" (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 "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" (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 :class "font-medium" "Something went wrong.")
(p (raw! error-msg)) (p error-msg)
(raw! order-html)) 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" (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")))) (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" (a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")) (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
(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" (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" (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" (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) (defcomp ~cart-header-child-oob (&key inner)
(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (div :id "cart-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html))) 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" (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" (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) (defcomp ~cart-auth-header-child-oob (&key inner)
(div :id "auth-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (div :id "auth-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html))) 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" (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" (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" (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) (defcomp ~cart-orders-header-child-oob (&key inner)
(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (div :id "orders-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html))) inner))

View File

@@ -8,10 +8,10 @@
"No image")) "No image"))
(defcomp ~cart-item-price (&key text) (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) (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 () (defcomp ~cart-item-no-price ()
(p :class "text-xs text-stone-500" "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")) " This item is no longer available or price has changed"))
(defcomp ~cart-item-brand (&key brand) (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) (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" (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-1 min-w-0"
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3" (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
(div :class "min-w-0" (div :class "min-w-0"
(h2 :class "text-sm sm:text-base md:text-lg font-semibold text-stone-900" (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))) (a :href prod-url :class "hover:text-emerald-700" title))
(raw! brand-html) (raw! deleted-html)) (when brand brand) (when deleted deleted))
(div :class "text-left sm:text-right" (raw! price-html))) (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 "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" (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") (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 "csrf_token" :value csrf)
(input :type "hidden" :name "count" :value minus) (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" "-")) (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)) (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" :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 "csrf_token" :value csrf)
(input :type "hidden" :name "count" :value plus) (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" "+"))) (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 () (defcomp ~cart-page-empty ()
(div :class "max-w-full px-3 py-3 space-y-3" (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")) (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"))))) (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 :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart" (div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" (raw! items-html) (raw! cal-html) (raw! tickets-html)) (div (section :class "space-y-3 sm:space-y-4" items cal tickets)
(raw! summary-html))))) summary))))

View File

@@ -6,43 +6,43 @@
(defcomp ~cart-order-item-no-img () (defcomp ~cart-order-item-no-img ()
(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")) (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 (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 :class "flex-1 flex justify-between gap-3"
(div (p :class "font-medium" (raw! title)) (div (p :class "font-medium" title)
(p :class "text-[11px] text-stone-500" (raw! product-id))) (p :class "text-[11px] text-stone-500" product-id))
(div :class "text-right whitespace-nowrap" (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" (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") (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) (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" (li :class "px-4 py-3 flex items-start justify-between text-sm"
(div (div :class "font-medium flex items-center gap-2" (div (div :class "font-medium flex items-center gap-2"
(raw! name) (span :class pill (raw! status))) name (span :class pill status))
(div :class "text-xs text-stone-500" (raw! date-str))) (div :class "text-xs text-stone-500" date-str))
(div :class "ml-4 font-medium" (raw! cost)))) (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" (section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order") (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) (defcomp ~cart-order-main (&key summary items cal)
(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary-html) (raw! items-html) (raw! cal-html))) (div :class "max-w-full px-3 py-3 space-y-4" summary items cal))
(defcomp ~cart-order-pay-btn (&key url) (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" (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")) (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" (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" (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" (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" (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") (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) (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" (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")) (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) (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" (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" (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" (raw! created)) (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" (raw! desc)) (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" (raw! total)) (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 (raw! status))) (td :class "px-3 py-2 align-top" (span :class pill status))
(td :class "px-3 py-0.5 align-top text-right" (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")))) (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" (td :colspan "5" :class "px-3 py-3"
(div :class "flex flex-col gap-2 text-xs" (div :class "flex flex-col gap-2 text-xs"
(div :class "flex items-center justify-between gap-2" (div :class "flex items-center justify-between gap-2"
(span :class "font-mono text-[11px] text-stone-700" (raw! order-id)) (span :class "font-mono text-[11px] text-stone-700" order-id)
(span :class pill (raw! status))) (span :class pill status))
(div :class "text-[11px] text-stone-500 break-words" (raw! created)) (div :class "text-[11px] text-stone-500 break-words" created)
(div :class "flex items-center justify-between gap-2" (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")))))) (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 () (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" (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."))) "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 "max-w-full px-3 py-3 space-y-3"
(div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80" (div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"
(table :class "min-w-full text-xs sm:text-sm" (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" "Total")
(th :class "px-3 py-2 text-left font-medium" "Status") (th :class "px-3 py-2 text-left font-medium" "Status")
(th :class "px-3 py-2 text-left font-medium"))) (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" (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" (div :class "space-y-1"
(p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout.")) (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) (defcomp ~cart-badge (&key icon text)
(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100" (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" (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) (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")) (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"))) (i :class "fa fa-store text-stone-400 text-xl" :aria-hidden "true")))
(defcomp ~cart-mp-subtitle (&key title) (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" (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" (div :class "flex items-start gap-4"
(raw! img-html) img
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" (raw! display-title)) (h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" display-title)
(raw! subtitle-html) (raw! badges-html)) subtitle badges)
(div :class "text-right flex-shrink-0" (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"))))) (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 "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 "flex items-start gap-4"
(div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0" (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")) (i :class "fa fa-shopping-cart text-amber-500 text-xl" :aria-hidden "true"))
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(h3 :class "text-base sm:text-lg font-semibold text-stone-900" "Other items") (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-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 () (defcomp ~cart-empty ()
(div :class "max-w-full px-3 py-3 space-y-3" (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")) (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")))) (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 "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" (h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment") (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.") (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) (input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code") (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)) (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" (form :method "post" :action action :class "w-full"
(input :type "hidden" :name "csrf_token" :value csrf) (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" (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) (defcomp ~cart-checkout-signin (&key href)
(div :class "w-full flex" (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" (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")))) (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" (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" (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") (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" (dl :class "space-y-2 text-xs sm:text-sm"
(div :class "flex items-center justify-between" (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" (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" (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") (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 "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

@@ -8,15 +8,19 @@ from __future__ import annotations
import os import os
from typing import Any from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import render, load_service_components from shared.sx.jinja_bridge import load_service_components
from shared.sexp.helpers import ( from shared.sx.helpers import (
call_url, root_header_html, search_desktop_html, call_url, root_header_sx, post_admin_header_sx,
search_mobile_html, full_page, oob_page, 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 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__))) load_service_components(os.path.dirname(os.path.dirname(__file__)))
@@ -24,10 +28,52 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
# Header helpers # Header helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _cart_header_html(ctx: dict, *, oob: bool = False) -> str: def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_sx)."""
if ctx.get("post") or not page_post:
return ctx
ctx = {**ctx, "post": {
"id": getattr(page_post, "id", None),
"slug": getattr(page_post, "slug", ""),
"title": getattr(page_post, "title", ""),
"feature_image": getattr(page_post, "feature_image", None),
}}
return ctx
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present (for post header row)."""
if ctx.get("container_nav"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
slug = post.get("slug", "")
if not post_id:
return ctx
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build post-level header row from page_post DTO, using shared helper."""
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return _shared_post_header_sx(ctx, oob=oob)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row.""" """Build the cart section header row."""
return render( return sx_call(
"menu-row", "menu-row-sx",
id="cart-row", level=1, colour="sky", id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"), link_href=call_url(ctx, "cart_url", "/"),
link_label="cart", icon="fa fa-shopping-cart", link_label="cart", icon="fa fa-shopping-cart",
@@ -35,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.""" """Build the per-page cart header row."""
slug = page_post.slug if page_post else "" slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160] title = ((page_post.title if page_post else None) or "")[:160]
label_html = "" label_parts = []
if page_post and page_post.feature_image: if page_post and page_post.feature_image:
label_html += render("cart-page-label-img", src=page_post.feature_image) label_parts.append(sx_call("cart-page-label-img", src=page_post.feature_image))
label_html += f"<span>{title}</span>" label_parts.append(f'(span "{escape(title)}")')
nav_html = render("cart-all-carts-link", href=call_url(ctx, "cart_url", "/")) label_sx = "(<> " + " ".join(label_parts) + ")"
return render( nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
"menu-row", return sx_call(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky", id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"), 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).""" """Build the account section header row (for orders)."""
return render( return sx_call(
"menu-row", "menu-row-sx",
id="auth-row", level=1, colour="sky", id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"), link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user", link_label="account", icon="fa-solid fa-user",
@@ -63,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.""" """Build the orders section header row."""
return render( return sx_call(
"menu-row", "menu-row-sx",
id="orders-row", level=2, colour="sky", id="orders-row", level=2, colour="sky",
link_href=list_url, link_label="Orders", icon="fa fa-gbp", link_href=list_url, link_label="Orders", icon="fa fa-gbp",
child_id="orders-header-child", child_id="orders-header-child",
@@ -77,13 +125,13 @@ def _orders_header_html(ctx: dict, list_url: str) -> str:
# Cart overview # 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.""" """Render a count badge."""
s = "s" if count != 1 else "" 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.""" """Render a single page group card for cart overview."""
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None) post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", []) cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
@@ -99,14 +147,15 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str:
return "" return ""
# Count badges # Count badges
badges = "" badge_parts = []
if product_count > 0: 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: 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: if ticket_count > 0:
badges += _badge_html("fa fa-ticket", ticket_count, "ticket") badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket"))
badges_html = render("cart-badges-wrap", badges_html=badges) badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""'
badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx))
if post: if post:
slug = post.slug if hasattr(post, "slug") else post.get("slug", "") slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
@@ -115,58 +164,58 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str:
cart_href = call_url(ctx, "cart_url", f"/{slug}/") cart_href = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image: 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: else:
img = render("cart-group-card-placeholder") img = sx_call("cart-group-card-placeholder")
mp_sub = "" mp_sub = ""
if market_place: if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "") 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: else:
mp_name = "" mp_name = ""
display_title = mp_name or title display_title = mp_name or title
return render( return sx_call(
"cart-group-card", "cart-group-card",
href=cart_href, img_html=img, display_title=display_title, href=cart_href, img=SxExpr(img), display_title=display_title,
subtitle_html=mp_sub, badges_html=badges_html, subtitle=SxExpr(mp_sub) if mp_sub else None,
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}", total=f"\u00a3{total:.2f}",
) )
else: else:
# Orphan items — use amber badges # Orphan items
badges_amber = badges.replace("bg-stone-100", "bg-amber-100") return sx_call(
badges_html_amber = render("cart-badges-wrap", badges_html=badges_amber)
return render(
"cart-orphan-card", "cart-orphan-card",
badges_html=badges_html_amber, badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}", total=f"\u00a3{total:.2f}",
) )
def _empty_cart_html() -> str: def _empty_cart_sx() -> str:
"""Empty cart state.""" """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.""" """Cart overview main panel."""
if not page_groups: 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) has_items = any(c for c in cards)
if not has_items: 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 # 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.""" """Render a single product cart item."""
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
from quart import url_for from quart import url_for
@@ -181,60 +230,60 @@ def _cart_item_html(item: Any, ctx: dict) -> str:
prod_url = market_product_url(slug) prod_url = market_product_url(slug)
if p.image: 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: else:
img = render("cart-item-no-img") img = sx_call("cart-item-no-img")
price_html = "" price_parts = []
if unit_price: 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: 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: 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 = "" deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
if getattr(item, "is_deleted", False):
deleted_html = render("cart-item-deleted")
brand_html = "" brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
if getattr(p, "brand", None):
brand_html = render("cart-item-brand", brand=p.brand)
line_total_html = "" line_total_sx = None
if unit_price: if unit_price:
lt = unit_price * item.quantity 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", "cart-item",
id=f"cart-item-{slug}", img_html=img, prod_url=prod_url, title=p.title, id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title,
brand_html=brand_html, deleted_html=deleted_html, price_html=price_html, 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_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
qty=str(item.quantity), plus=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.""" """Render calendar booking entries in cart."""
if not entries: if not entries:
return "" return ""
items = "" parts = []
for e in entries: for e in entries:
name = getattr(e, "name", None) or getattr(e, "calendar_name", "") name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else "" start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None) end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0 cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else "" end_str = f" \u2013 {end}" if end else ""
items += render( parts.append(sx_call(
"cart-cal-entry", "cart-cal-entry",
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}", 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.""" """Render ticket groups in cart."""
if not ticket_groups: if not ticket_groups:
return "" return ""
@@ -243,7 +292,7 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
csrf = generate_csrf_token() csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_ticket_quantity") qty_url = url_for("cart_global.update_ticket_quantity")
items = "" parts = []
for tg in ticket_groups: for tg in ticket_groups:
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "") name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
@@ -260,22 +309,26 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
if end_at: if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" 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_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None
tt_hidden = render("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else "" 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", "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, 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), minus=str(max(quantity - 1, 0)), qty=str(quantity),
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}", 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: total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""Render the order summary sidebar.""" """Render the order summary sidebar."""
from shared.browser.app.csrf import generate_csrf_token from shared.browser.app.csrf import generate_csrf_token
@@ -307,38 +360,41 @@ def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
action = url_for("cart_global.checkout") action = url_for("cart_global.checkout")
from shared.utils import route_prefix from shared.utils import route_prefix
action = route_prefix() + action action = route_prefix() + action
checkout_html = render( checkout_sx = sx_call(
"cart-checkout-form", "cart-checkout-form",
action=action, csrf=csrf, label=f" Checkout as {user.email}", action=action, csrf=csrf, label=f" Checkout as {user.email}",
) )
else: else:
href = login_url(request.url) 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", "cart-summary-panel",
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}", 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, tickets: list, ticket_groups: list,
total_fn: Any, cal_total_fn: Any, total_fn: Any, cal_total_fn: Any,
ticket_total_fn: Any) -> str: ticket_total_fn: Any) -> str:
"""Page cart main panel.""" """Page cart main panel."""
if not cart and not cal_entries and not tickets: 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) item_parts = [_cart_item_sx(item, ctx) for item in cart]
cal_html = _calendar_entries_html(cal_entries) items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""'
tickets_html = _ticket_groups_html(ticket_groups, ctx) cal_sx = _calendar_entries_sx(cal_entries)
summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn) 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", "cart-page-panel",
items_html=items_html, cal_html=cal_html, items=SxExpr(items_sx),
tickets_html=tickets_html, summary_html=summary_html, cal=SxExpr(cal_sx) if cal_sx else None,
tickets=SxExpr(tickets_sx) if tickets_sx else None,
summary=SxExpr(summary_sx),
) )
@@ -346,7 +402,7 @@ def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
# Orders list (same pattern as orders service) # 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.""" """Render a single order as desktop table row + mobile card."""
status = order.status or "pending" status = order.status or "pending"
sl = status.lower() sl = status.lower()
@@ -359,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" 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}" total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
desktop = render( desktop = sx_call(
"cart-order-row-desktop", "cart-order-row-desktop",
order_id=f"#{order.id}", created=created, desc=order.description or "", order_id=f"#{order.id}", created=created, desc=order.description or "",
total=total, pill=pill_cls, status=status, detail_url=detail_url, 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_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", "cart-order-row-mobile",
order_id=f"#{order.id}", pill=mobile_pill, status=status, order_id=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, detail_url=detail_url, 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: url_for_fn: Any, qs_fn: Any) -> str:
"""Render order rows + infinite scroll sentinel.""" """Render order rows + infinite scroll sentinel."""
from shared.utils import route_prefix from shared.utils import route_prefix
pfx = route_prefix() pfx = route_prefix()
parts = [ 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 for o in orders
] ]
if page < total_pages: if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
parts.append(render( parts.append(sx_call(
"infinite-scroll", "infinite-scroll",
url=next_url, page=page, total_pages=total_pages, url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5, id_prefix="orders", colspan=5,
)) ))
else: 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.""" """Main panel for orders list."""
if not orders: if not orders:
return render("cart-orders-empty") return sx_call("cart-orders-empty")
return render("cart-orders-table", rows_html=rows_html) 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.""" """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 # Single order detail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _order_items_html(order: Any) -> str: def _order_items_sx(order: Any) -> str:
"""Render order items list.""" """Render order items list."""
if not order or not order.items: if not order or not order.items:
return "" return ""
items = "" parts = []
for item in order.items: for item in order.items:
prod_url = market_product_url(item.product_slug) prod_url = market_product_url(item.product_slug)
if item.product_image: if item.product_image:
img = render( img = sx_call(
"cart-order-item-img", "cart-order-item-img",
src=item.product_image, alt=item.product_title or "Product image", src=item.product_image, alt=item.product_title or "Product image",
) )
else: else:
img = render("cart-order-item-no-img") img = sx_call("cart-order-item-no-img")
items += render( parts.append(sx_call(
"cart-order-item", "cart-order-item",
prod_url=prod_url, img_html=img, prod_url=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product", title=item.product_title or "Unknown product",
product_id=f"Product ID: {item.product_id}", product_id=f"Product ID: {item.product_id}",
qty=f"Qty: {item.quantity}", qty=f"Qty: {item.quantity}",
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", 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.""" """Order summary card."""
return render( return sx_call(
"order-summary-card", "order-summary-card",
order_id=order.id, order_id=order.id,
created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
@@ -451,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.""" """Render calendar bookings for an order."""
if not calendar_entries: if not calendar_entries:
return "" return ""
items = "" parts = []
for e in calendar_entries: for e in calendar_entries:
st = e.state or "" st = e.state or ""
pill = ( pill = (
@@ -468,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 "" ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at: if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
items += render( parts.append(sx_call(
"cart-order-cal-entry", "cart-order-cal-entry",
name=e.name, pill=pill_cls, status=st.capitalize(), name=e.name, pill=pill_cls, status=st.capitalize(),
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}", 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.""" """Main panel for single order detail."""
summary = _order_summary_html(order) summary = _order_summary_sx(order)
return render( items = _order_items_sx(order)
cal = _order_calendar_items_sx(calendar_entries)
return sx_call(
"cart-order-main", "cart-order-main",
summary_html=summary, items_html=_order_items_html(order), summary=SxExpr(summary),
cal_html=_order_calendar_items_html(calendar_entries), 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: pay_url: str, csrf_token: str) -> str:
"""Filter section for single order detail.""" """Filter section for single order detail."""
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
status = order.status or "pending" status = order.status or "pending"
pay = "" pay_sx = None
if status != "paid": 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", "cart-order-filter",
info=f"Placed {created} \u00b7 Status: {status}", 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,
) )
@@ -509,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: async def render_overview_page(ctx: dict, page_groups: list) -> str:
"""Full page: cart overview.""" """Full page: cart overview."""
main = _overview_main_panel_html(page_groups, ctx) main = _overview_main_panel_sx(page_groups, ctx)
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=main) return full_page_sx(ctx, header_rows=hdr, content=main)
async def render_overview_oob(ctx: dict, page_groups: list) -> str: async def render_overview_oob(ctx: dict, page_groups: list) -> str:
"""OOB response for cart overview.""" """OOB response for cart overview."""
main = _overview_main_panel_html(page_groups, ctx) main = _overview_main_panel_sx(page_groups, ctx)
oobs = root_header_html(ctx, oob=True) oobs = root_header_sx(ctx, oob=True)
return oob_page(ctx, oobs_html=oobs, content_html=main) return oob_page_sx(oobs=oobs, content=main)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -530,16 +592,17 @@ async def render_page_cart_page(ctx: dict, page_post: Any,
ticket_groups: list, total_fn: Any, ticket_groups: list, total_fn: Any,
cal_total_fn: Any, ticket_total_fn: Any) -> str: cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""Full page: page-specific cart.""" """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) total_fn, cal_total_fn, ticket_total_fn)
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
child = _cart_header_html(ctx) child = _cart_header_sx(ctx)
page_hdr = _page_cart_header_html(ctx, page_post) page_hdr = _page_cart_header_sx(ctx, page_post)
hdr += render( nested = sx_call(
"cart-header-child-nested", "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, async def render_page_cart_oob(ctx: dict, page_post: Any,
@@ -547,14 +610,14 @@ async def render_page_cart_oob(ctx: dict, page_post: Any,
ticket_groups: list, total_fn: Any, ticket_groups: list, total_fn: Any,
cal_total_fn: Any, ticket_total_fn: Any) -> str: cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""OOB response for page cart.""" """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) total_fn, cal_total_fn, ticket_total_fn)
oobs = ( child_oob = sx_call("cart-header-child-oob",
render("cart-header-child-oob", inner_html=_page_cart_header_html(ctx, page_post)) inner=SxExpr(_page_cart_header_sx(ctx, page_post)))
+ _cart_header_html(ctx, oob=True) cart_hdr_oob = _cart_header_sx(ctx, oob=True)
+ root_header_html(ctx, oob=True) root_hdr_oob = root_header_sx(ctx, oob=True)
) oobs = "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
return oob_page(ctx, oobs_html=oobs, content_html=main) return oob_page_sx(oobs=oobs, content=main)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -572,27 +635,29 @@ async def render_orders_page(ctx: dict, orders: list, page: int,
ctx["search_count"] = search_count ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders") list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_html(orders, rows) main = _orders_main_panel_sx(orders, rows)
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
hdr += render( auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
"cart-auth-header-child", "cart-auth-header-child",
auth_html=_auth_header_html(ctx), auth=SxExpr(auth), orders=SxExpr(orders_hdr),
orders_html=_orders_header_html(ctx, list_url),
) )
header_rows = "(<> " + hdr + " " + auth_child + ")"
return full_page(ctx, header_rows_html=hdr, return full_page_sx(ctx, header_rows=header_rows,
filter_html=_orders_summary_html(ctx), filter=_orders_summary_sx(ctx),
aside_html=search_desktop_html(ctx), aside=search_desktop_sx(ctx),
content_html=main) content=main)
async def render_orders_rows(ctx: dict, orders: list, page: int, async def render_orders_rows(ctx: dict, orders: list, page: int,
total_pages: int, url_for_fn: Any, total_pages: int, url_for_fn: Any,
qs_fn: Any) -> str: qs_fn: Any) -> str:
"""Pagination: just the table rows.""" """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, async def render_orders_oob(ctx: dict, orders: list, page: int,
@@ -606,22 +671,21 @@ async def render_orders_oob(ctx: dict, orders: list, page: int,
ctx["search_count"] = search_count ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders") list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_html(orders, rows) main = _orders_main_panel_sx(orders, rows)
oobs = ( auth_oob = _auth_header_sx(ctx, oob=True)
_auth_header_html(ctx, oob=True) auth_child_oob = sx_call(
+ render(
"cart-auth-header-child-oob", "cart-auth-header-child-oob",
inner_html=_orders_header_html(ctx, list_url), inner=SxExpr(_orders_header_sx(ctx, list_url)),
)
+ root_header_html(ctx, oob=True)
) )
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
return oob_page(ctx, oobs_html=oobs, return oob_page_sx(oobs=oobs,
filter_html=_orders_summary_html(ctx), filter=_orders_summary_sx(ctx),
aside_html=search_desktop_html(ctx), aside=search_desktop_sx(ctx),
content_html=main) content=main)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -641,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) 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) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_html(order, calendar_entries) main = _order_main_sx(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
order_row = render( order_row = sx_call(
"menu-row", "menu-row-sx",
id="order-row", level=3, colour="sky", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
) )
hdr += render( order_child = sx_call(
"cart-order-header-child", "cart-order-header-child",
auth_html=_auth_header_html(ctx), auth=SxExpr(_auth_header_sx(ctx)),
orders_html=_orders_header_html(ctx, list_url), orders=SxExpr(_orders_header_sx(ctx, list_url)),
order_html=order_row, 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, async def render_order_oob(ctx: dict, order: Any,
@@ -673,89 +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) 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) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_html(order, calendar_entries) main = _order_main_sx(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = render( order_row_oob = sx_call(
"menu-row", "menu-row-sx",
id="order-row", level=3, colour="sky", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True, oob=True,
) )
oobs = ( orders_child_oob = sx_call("cart-orders-header-child-oob",
render("cart-orders-header-child-oob", inner_html=order_row_oob) inner=SxExpr(order_row_oob))
+ root_header_html(ctx, oob=True) 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 # Public API: Checkout error
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _checkout_error_filter_html() -> str: def _checkout_error_filter_sx() -> str:
return render("cart-checkout-error-filter") 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." err_msg = error or "Unexpected error while creating the hosted checkout session."
order_html = "" order_sx = None
if order: 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("/") back_url = cart_url("/")
return render( return sx_call(
"cart-checkout-error-content", "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: async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error.""" """Full page: checkout error."""
hdr = root_header_html(ctx) hdr = root_header_sx(ctx)
filt = _checkout_error_filter_html() filt = _checkout_error_filter_sx()
content = _checkout_error_content_html(error, order) content = _checkout_error_content_sx(error, order)
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content) return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page admin (/<page_slug>/admin/) # Page admin (/<page_slug>/admin/)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _cart_page_admin_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str: def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
"""Build the page-level admin header row.""" selected: str = "") -> str:
from quart import url_for """Build the page-level admin header row -- delegates to shared helper."""
link_href = url_for("page_admin.admin") slug = page_post.slug if page_post else ""
return render("menu-row", id="page-admin-row", level=2, colour="sky", ctx = _ensure_post_ctx(ctx, page_post)
link_href=link_href, link_label="admin", icon="fa fa-cog", return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
child_id="page-admin-header-child", oob=oob)
def _cart_payments_header_html(ctx: dict, *, oob: bool = False) -> str: def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Build the payments section header row.""" """Admin overview panel -- links to sub-admin pages."""
from quart import url_for
link_href = url_for("page_admin.payments")
return render("menu-row", id="payments-row", level=3, colour="sky",
link_href=link_href, link_label="Payments",
icon="fa fa-credit-card",
child_id="payments-header-child", oob=oob)
def _cart_admin_main_panel_html(ctx: dict) -> str:
"""Admin overview panel — links to sub-admin pages."""
from quart import url_for from quart import url_for
payments_href = url_for("page_admin.payments") payments_href = url_for("page_admin.payments")
return ( return (
'<div id="main-panel">' '(div :id "main-panel"'
'<div class="flex items-center justify-between p-3 border-b">' ' (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>' ' (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</a>' f' (a :href "{payments_href}" :class "text-sm underline" "configure")))'
'</div>'
'</div>'
) )
def _cart_payments_main_panel_html(ctx: dict) -> str: def _cart_payments_main_panel_sx(ctx: dict) -> str:
"""Render SumUp payment config form.""" """Render SumUp payment config form."""
from quart import url_for from quart import url_for
csrf_token = ctx.get("csrf_token") csrf_token = ctx.get("csrf_token")
@@ -769,7 +823,7 @@ def _cart_payments_main_panel_html(ctx: dict) -> str:
placeholder = "--------" if sumup_configured else "sup_sk_..." 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" 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", return sx_call("cart-payments-panel",
update_url=update_url, csrf=csrf, update_url=update_url, csrf=csrf,
merchant_code=merchant_code, placeholder=placeholder, merchant_code=merchant_code, placeholder=placeholder,
input_cls=input_cls, sumup_configured=sumup_configured, input_cls=input_cls, sumup_configured=sumup_configured,
@@ -782,26 +836,19 @@ def _cart_payments_main_panel_html(ctx: dict) -> str:
async def render_cart_admin_page(ctx: dict, page_post: Any) -> str: async def render_cart_admin_page(ctx: dict, page_post: Any) -> str:
"""Full page: cart page admin overview.""" """Full page: cart page admin overview."""
content = _cart_admin_main_panel_html(ctx) content = _cart_admin_main_panel_sx(ctx)
hdr = root_header_html(ctx) root_hdr = root_header_sx(ctx)
child = _page_cart_header_html(ctx, page_post) + _cart_page_admin_header_html(ctx, page_post) post_hdr = await _post_header_sx(ctx, page_post)
hdr += render("cart-header-child-nested", admin_hdr = _cart_page_admin_header_sx(ctx, page_post)
outer_html=_cart_header_html(ctx), inner_html=child) header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
return full_page(ctx, header_rows_html=hdr, content_html=content) return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str: async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
"""OOB response: cart page admin overview.""" """OOB response: cart page admin overview."""
content = _cart_admin_main_panel_html(ctx) content = _cart_admin_main_panel_sx(ctx)
oobs = ( oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True)
_cart_page_admin_header_html(ctx, page_post, oob=True) return oob_page_sx(oobs=oobs, content=content)
+ render("cart-header-child-oob",
inner_html=_page_cart_header_html(ctx, page_post)
+ _cart_page_admin_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=content)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -810,32 +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: async def render_cart_payments_page(ctx: dict, page_post: Any) -> str:
"""Full page: payments config.""" """Full page: payments config."""
content = _cart_payments_main_panel_html(ctx) content = _cart_payments_main_panel_sx(ctx)
hdr = root_header_html(ctx) root_hdr = root_header_sx(ctx)
admin_hdr = _cart_page_admin_header_html(ctx, page_post) post_hdr = await _post_header_sx(ctx, page_post)
payments_hdr = _cart_payments_header_html(ctx) admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected="payments")
child = _page_cart_header_html(ctx, page_post) + admin_hdr + payments_hdr header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
hdr += render("cart-header-child-nested", return full_page_sx(ctx, header_rows=header_rows, content=content)
outer_html=_cart_header_html(ctx), inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str: async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str:
"""OOB response: payments config.""" """OOB response: payments config."""
content = _cart_payments_main_panel_html(ctx) content = _cart_payments_main_panel_sx(ctx)
admin_hdr = _cart_page_admin_header_html(ctx, page_post) oobs = _cart_page_admin_header_sx(ctx, page_post, oob=True, selected="payments")
payments_hdr = _cart_payments_header_html(ctx) return oob_page_sx(oobs=oobs, content=content)
oobs = (
_cart_payments_header_html(ctx, oob=True)
+ render("cart-header-child-oob",
inner_html=_page_cart_header_html(ctx, page_post)
+ admin_hdr + payments_hdr)
+ _cart_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, content_html=content)
def render_cart_payments_panel(ctx: dict) -> str: def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response.""" """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 ;; Cart ticket components
(defcomp ~cart-ticket-type-name (&key name) (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) (defcomp ~cart-ticket-type-hidden (&key value)
(input :type "hidden" :name "ticket_type_id" :value 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" (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-1 min-w-0"
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3" (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
(div :class "min-w-0" (div :class "min-w-0"
(h3 :class "text-sm sm:text-base font-semibold text-stone-900" (raw! name)) (h3 :class "text-sm sm:text-base font-semibold text-stone-900" name)
(raw! type-name-html) type-name
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! date-str))) (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" date-str))
(div :class "text-left sm:text-right" (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 "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" (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") (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 "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id) (input :type "hidden" :name "entry_id" :value entry-id)
(raw! type-hidden-html) type-hidden
(input :type "hidden" :name "count" :value minus) (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" "-")) (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)) (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" :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 "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id) (input :type "hidden" :name "entry_id" :value entry-id)
(raw! type-hidden-html) type-hidden
(input :type "hidden" :name "count" :value plus) (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" "+"))) (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" (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" (div :class "mt-6 border-t border-stone-200 pt-4"
(h2 :class "text-base font-semibold mb-2" (h2 :class "text-base font-semibold mb-2"
(i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets") (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) %} {% 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 #} {# Empty cart #}
{% if not cart and not calendar_cart_entries and not ticket_cart_entries %} {% 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"> <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 <form
action="{{ qty_url }}" action="{{ qty_url }}"
method="post" method="post"
hx-post="{{ qty_url }}" sx-post="{{ qty_url }}"
hx-swap="none" sx-swap="none"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}"> <input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
@@ -127,8 +127,8 @@
<form <form
action="{{ qty_url }}" action="{{ qty_url }}"
method="post" method="post"
hx-post="{{ qty_url }}" sx-post="{{ qty_url }}"
hx-swap="none" sx-swap="none"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}"> <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) %} {% 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"> <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"> <h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
Order summary Order summary

View File

@@ -1,5 +1,5 @@
{% macro mini(oob=False, count=None) %} {% 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_count is set by the context processor in all apps.
Cart app computes it from g.cart + calendar_cart_entries; Cart app computes it from g.cart + calendar_cart_entries;
other apps get it from the cart internal API. other apps get it from the cart internal API.

View File

@@ -102,43 +102,10 @@
{% if page < total_pages|int %} {% if page < total_pages|int %}
<tr <tr
id="orders-sentinel-{{ page }}" id="orders-sentinel-{{ page }}"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}" sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinel:retry" sx-trigger="intersect once delay:250ms"
hx-swap="outerHTML" sx-swap="outerHTML"
_=" sx-retry="exponential:1000:30000"
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()
"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-hidden="true" aria-hidden="true"

View File

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

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