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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -44,14 +44,14 @@ async def account_context() -> dict:
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
cart_mini, auth_menu, nav_tree = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "account", "path": request.path}),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
ctx["cart_mini"] = cart_mini
ctx["auth_menu"] = auth_menu
ctx["nav_tree"] = nav_tree
return ctx

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,12 @@
(defcomp ~account-login-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
(raw! error))))
error)))
(defcomp ~account-login-form (&key error-html action csrf-token email)
(defcomp ~account-login-form (&key error action csrf-token email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
(raw! error-html)
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
@@ -22,13 +22,13 @@
(defcomp ~account-device-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
(raw! error))))
error)))
(defcomp ~account-device-form (&key error-html action csrf-token code)
(defcomp ~account-device-form (&key error action csrf-token code)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Authorize device")
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
(raw! error-html)
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
@@ -48,11 +48,11 @@
(defcomp ~account-check-email-error (&key error)
(when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
(raw! error))))
error)))
(defcomp ~account-check-email (&key email error-html)
(defcomp ~account-check-email (&key email error)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
(raw! error-html)))
error))

View File

@@ -3,15 +3,15 @@
(defcomp ~account-error-banner (&key error)
(when error
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
(raw! error))))
error)))
(defcomp ~account-user-email (&key email)
(when email
(p :class "text-sm text-stone-500 mt-1" (raw! email))))
(p :class "text-sm text-stone-500 mt-1" email)))
(defcomp ~account-user-name (&key name)
(when name
(p :class "text-sm text-stone-600" (raw! name))))
(p :class "text-sm text-stone-600" name)))
(defcomp ~account-logout-form (&key csrf-token)
(form :action "/auth/logout/" :method "post"
@@ -22,27 +22,27 @@
(defcomp ~account-label-item (&key name)
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
(raw! name)))
name))
(defcomp ~account-labels-section (&key items-html)
(when items-html
(defcomp ~account-labels-section (&key items)
(when items
(div
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
(div :class "flex flex-wrap gap-2" (raw! items-html)))))
(div :class "flex flex-wrap gap-2" items))))
(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html)
(defcomp ~account-main-panel (&key error email name logout labels)
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"
(raw! error-html)
error
(div :class "flex items-center justify-between"
(div
(h1 :class "text-xl font-semibold tracking-tight" "Account")
(raw! email-html)
(raw! name-html))
(raw! logout-html))
(raw! labels-html))))
email
name)
logout)
labels)))
;; Header child wrapper
(defcomp ~account-header-child (&key inner-html)
(defcomp ~account-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html)))
inner))

View File

@@ -2,36 +2,36 @@
(defcomp ~account-newsletter-desc (&key description)
(when description
(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description))))
(p :class "text-xs text-stone-500 mt-0.5 truncate" description)))
(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls)
(div :id id :class "flex items-center"
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
:class cls :role "switch" :aria-checked checked
(span :class knob-cls))))
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
(div :id id :class "flex items-center"
(button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML"
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
:class "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
:role "switch" :aria-checked "false"
(span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"))))
(defcomp ~account-newsletter-item (&key name desc-html toggle-html)
(defcomp ~account-newsletter-item (&key name desc toggle)
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"
(div :class "min-w-0 flex-1"
(p :class "text-sm font-medium text-stone-800" (raw! name))
(raw! desc-html))
(div :class "ml-4 flex-shrink-0" (raw! toggle-html))))
(p :class "text-sm font-medium text-stone-800" name)
desc)
(div :class "ml-4 flex-shrink-0" toggle)))
(defcomp ~account-newsletter-list (&key items-html)
(div :class "divide-y divide-stone-100" (raw! items-html)))
(defcomp ~account-newsletter-list (&key items)
(div :class "divide-y divide-stone-100" items))
(defcomp ~account-newsletter-empty ()
(p :class "text-sm text-stone-500" "No newsletters available."))
(defcomp ~account-newsletters-panel (&key list-html)
(defcomp ~account-newsletters-panel (&key list)
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
(raw! list-html))))
list)))

View File

@@ -9,13 +9,13 @@ from __future__ import annotations
import os
from typing import Any
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page,
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, sx_call, SxExpr,
root_header_sx, full_page_sx, header_child_sx, oob_page_sx,
)
# Load account-specific .sexpr components at import time
# Load account-specific .sx components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
@@ -23,51 +23,53 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
# Header helpers
# ---------------------------------------------------------------------------
def _auth_nav_html(ctx: dict) -> str:
def _auth_nav_sx(ctx: dict) -> str:
"""Auth section desktop nav items."""
html = render(
"nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html:
html += account_nav_html
return html
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return render(
"menu-row",
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
nav_html=_auth_nav_html(ctx),
nav=SxExpr(_auth_nav_sx(ctx)),
child_id="auth-header-child", oob=oob,
)
def _auth_nav_mobile_html(ctx: dict) -> str:
def _auth_nav_mobile_sx(ctx: dict) -> str:
"""Mobile nav menu for auth section."""
html = render(
"nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html:
html += account_nav_html
return html
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# ---------------------------------------------------------------------------
def _account_main_panel_html(ctx: dict) -> str:
def _account_main_panel_sx(ctx: dict) -> str:
"""Account info panel with user details and logout."""
from quart import g
from shared.browser.app.csrf import generate_csrf_token
@@ -75,30 +77,33 @@ def _account_main_panel_html(ctx: dict) -> str:
user = getattr(g, "user", None)
error = ctx.get("error", "")
error_html = render("account-error-banner", error=error) if error else ""
error_sx = sx_call("account-error-banner", error=error) if error else ""
user_email_html = ""
user_name_html = ""
user_email_sx = ""
user_name_sx = ""
if user:
user_email_html = render("account-user-email", email=user.email)
user_email_sx = sx_call("account-user-email", email=user.email)
if user.name:
user_name_html = render("account-user-name", name=user.name)
user_name_sx = sx_call("account-user-name", name=user.name)
logout_html = render("account-logout-form", csrf_token=generate_csrf_token())
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
labels_html = ""
labels_sx = ""
if user and hasattr(user, "labels") and user.labels:
label_items = "".join(
render("account-label-item", name=label.name)
label_items = " ".join(
sx_call("account-label-item", name=label.name)
for label in user.labels
)
labels_html = render("account-labels-section", items_html=label_items)
labels_sx = sx_call("account-labels-section",
items=SxExpr("(<> " + label_items + ")"))
return render(
return sx_call(
"account-main-panel",
error_html=error_html, email_html=user_email_html,
name_html=user_name_html, logout_html=logout_html,
labels_html=labels_html,
error=SxExpr(error_sx) if error_sx else None,
email=SxExpr(user_email_sx) if user_email_sx else None,
name=SxExpr(user_name_sx) if user_name_sx else None,
logout=SxExpr(logout_sx),
labels=SxExpr(labels_sx) if labels_sx else None,
)
@@ -106,7 +111,7 @@ def _account_main_panel_html(ctx: dict) -> str:
# Newsletters (GET /newsletters/)
# ---------------------------------------------------------------------------
def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str:
def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
"""Render a single newsletter toggle switch."""
nid = un.newsletter_id
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
@@ -118,7 +123,7 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
return render(
return sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
@@ -129,9 +134,9 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st
)
def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str:
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
return render(
return sx_call(
"account-newsletter-toggle-off",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
@@ -139,7 +144,7 @@ def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> s
)
def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str:
"""Newsletters management panel."""
from shared.browser.app.csrf import generate_csrf_token
@@ -152,28 +157,30 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
nl = item["newsletter"]
un = item.get("un")
desc_html = render(
desc_sx = sx_call(
"account-newsletter-desc", description=nl.description
) if nl.description else ""
if un:
toggle = _newsletter_toggle_html(un, account_url_fn, csrf)
toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
else:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf)
toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf)
items.append(render(
items.append(sx_call(
"account-newsletter-item",
name=nl.name, desc_html=desc_html, toggle_html=toggle,
name=nl.name,
desc=SxExpr(desc_sx) if desc_sx else None,
toggle=SxExpr(toggle),
))
list_html = render(
list_sx = sx_call(
"account-newsletter-list",
items_html="".join(items),
items=SxExpr("(<> " + " ".join(items) + ")"),
)
else:
list_html = render("account-newsletter-empty")
list_sx = sx_call("account-newsletter-empty")
return render("account-newsletters-panel", list_html=list_html)
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
# ---------------------------------------------------------------------------
@@ -189,11 +196,12 @@ def _login_page_content(ctx: dict) -> str:
email = ctx.get("email", "")
action = url_for("auth.start_login")
error_html = render("account-login-error", error=error) if error else ""
error_sx = sx_call("account-login-error", error=error) if error else ""
return render(
return sx_call(
"account-login-form",
error_html=error_html, action=action,
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), email=email,
)
@@ -207,18 +215,19 @@ def _device_page_content(ctx: dict) -> str:
code = ctx.get("code", "")
action = url_for("auth.device_submit")
error_html = render("account-device-error", error=error) if error else ""
error_sx = sx_call("account-device-error", error=error) if error else ""
return render(
return sx_call(
"account-device-form",
error_html=error_html, action=action,
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), code=code,
)
def _device_approved_content() -> str:
"""Device approved success content."""
return render("account-device-approved")
return sx_call("account-device-approved")
# ---------------------------------------------------------------------------
@@ -227,28 +236,26 @@ def _device_approved_content() -> str:
async def render_account_page(ctx: dict) -> str:
"""Full page: account dashboard."""
main = _account_main_panel_html(ctx)
main = _account_main_panel_sx(ctx)
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return full_page_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_account_oob(ctx: dict) -> str:
"""OOB response for account dashboard."""
main = _account_main_panel_html(ctx)
main = _account_main_panel_sx(ctx)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
@@ -257,28 +264,26 @@ async def render_account_oob(ctx: dict) -> str:
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
"""Full page: newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list)
main = _newsletters_panel_sx(ctx, newsletter_list)
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return full_page_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
"""OOB response for newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list)
main = _newsletters_panel_sx(ctx, newsletter_list)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
@@ -287,24 +292,22 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content."""
hdr = root_header_html(ctx)
hdr += render("account-header-child", inner_html=_auth_header_html(ctx))
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page(ctx, header_rows_html=hdr,
content_html=page_fragment_html,
menu_html=_auth_nav_mobile_html(ctx))
return full_page_sx(ctx, header_rows=header_rows,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sx(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
"""OOB response for fragment pages."""
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page(ctx, oobs_html=oobs,
content_html=page_fragment_html,
menu_html=_auth_nav_mobile_html(ctx))
return oob_page_sx(oobs=oobs,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
@@ -313,26 +316,26 @@ async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
@@ -343,13 +346,14 @@ def _check_email_content(email: str, email_error: str | None = None) -> str:
"""Check email confirmation content."""
from markupsafe import escape
error_html = render(
error_sx = sx_call(
"account-check-email-error", error=str(escape(email_error))
) if email_error else ""
return render(
return sx_call(
"account-check-email",
email=str(escape(email)), error_html=error_html,
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
@@ -357,22 +361,16 @@ async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
def render_newsletter_toggle_html(un) -> str:
"""Render a newsletter toggle switch for POST response."""
from shared.browser.app.csrf import generate_csrf_token
return _newsletter_toggle_html(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p,
generate_csrf_token())
def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response (uses account_url)."""
@@ -382,4 +380,13 @@ def render_newsletter_toggle(un) -> str:
if account_url_fn is None:
from shared.infrastructure.urls import account_url
account_url_fn = account_url
return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token())
return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token())
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _sx_escape(s: str) -> str:
"""Escape a string for embedding in sx string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')

View File

@@ -1,9 +1,9 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
sx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-target="#nl-{{ un.newsletter_id }}"
sx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"

View File

@@ -22,10 +22,10 @@
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
sx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-target="#nl-{{ item.newsletter.id }}"
sx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
role="switch"
aria-checked="false"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,19 @@
(defcomp ~blog-header-label ()
(div))
(defcomp ~blog-container-nav (&key container-nav-html)
(defcomp ~blog-container-nav (&key container-nav)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" (raw! container-nav-html)))
:id "entries-calendars-nav-wrapper" container-nav))
(defcomp ~blog-admin-label ()
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label)
(div :class "relative nav-group" (a :href href :class 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
: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)
(<> (i :class icon :aria-hidden "true") " " label))

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,9 +32,9 @@
<div class="flex gap-2 flex-shrink-0">
<button
type="button"
hx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
hx-target="#menu-item-form"
hx-swap="innerHTML"
sx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
sx-target="#menu-item-form"
sx-swap="innerHTML"
class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded">
<i class="fa fa-edit"></i> Edit
</button>
@@ -47,11 +47,11 @@
data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}"
hx-trigger="confirmed"
hx-target="#menu-items-list"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}"
sx-trigger="confirmed"
sx-target="#menu-items-list"
sx-swap="innerHTML"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800">
<i class="fa fa-trash"></i> Delete
</button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
<div id="calendar-view-{{ calendar.id }}"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}"
hx-trigger="entryToggled from:body"
hx-swap="outerHTML">
sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}"
sx-trigger="entryToggled from:body"
sx-swap="outerHTML">
{# Month/year navigation #}
<header class="flex items-center justify-center mb-4">
<nav class="flex items-center gap-2 text-xl">
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&laquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_month_year }}&month={{ prev_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_month_year, month=prev_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&lsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_year }}&month={{ month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_year, month=month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&laquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_month_year }}&month={{ prev_month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_month_year, month=prev_month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&lsaquo;</a>
<div class="px-3 font-medium">{{ month_name }} {{ year }}</div>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_month_year }}&month={{ next_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_month_year, month=next_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&rsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&raquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_month_year }}&month={{ next_month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_month_year, month=next_month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&rsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_year }}&month={{ month }}" sx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_year, month=month) }}" sx-target="#calendar-view-{{ calendar.id }}" sx-swap="outerHTML">&raquo;</a>
</nav>
</header>
@@ -45,12 +45,12 @@
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
sx-trigger="confirmed"
sx-target="#associated-entries-list"
sx-swap="outerHTML"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))"
>
<i class="fa fa-times"></i>
</button>
@@ -67,12 +67,12 @@
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
sx-trigger="confirmed"
sx-target="#associated-entries-list"
sx-swap="outerHTML"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))"
>
<span class="truncate block">{{ e.name }}</span>
</button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,38 +7,38 @@
(a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
(defcomp ~cart-header-child (&key inner-html)
(defcomp ~cart-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html)))
inner))
(defcomp ~cart-header-child-nested (&key outer-html inner-html)
(defcomp ~cart-header-child-nested (&key outer inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! outer-html)
outer
(div :id "cart-header-child" :class "flex flex-col w-full items-center"
(raw! inner-html))))
inner)))
(defcomp ~cart-header-child-oob (&key inner-html)
(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-header-child-oob (&key inner)
(div :id "cart-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-auth-header-child (&key auth-html orders-html)
(defcomp ~cart-auth-header-child (&key auth orders)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! auth-html)
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
(raw! orders-html))))
orders)))
(defcomp ~cart-auth-header-child-oob (&key inner-html)
(div :id "auth-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-auth-header-child-oob (&key inner)
(div :id "auth-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-order-header-child (&key auth-html orders-html order-html)
(defcomp ~cart-order-header-child (&key auth orders order)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
(raw! auth-html)
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
(raw! orders-html)
orders
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
(raw! order-html)))))
order))))
(defcomp ~cart-orders-header-child-oob (&key inner-html)
(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
(raw! inner-html)))
(defcomp ~cart-orders-header-child-oob (&key inner)
(div :id "orders-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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