73 Commits

Author SHA1 Message Date
2e23feb09e Merge branch 'worktree-macros-essays' into macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m22s
2026-03-04 14:39:57 +00:00
45c5e4a0db Add register_sx_layout infrastructure, convert account/federation/orders
Phase 0: Add _ctx_to_env() and render_to_sx_with_env() to shared/sx/helpers.py,
register_sx_layout() to shared/sx/layouts.py, and ~root-header/~root-mobile
wrapper defcomps to layout.sx. Convert built-in "root" layout to .sx.

Phases 1-3: Convert account (65→19 lines), federation (105→97 lines),
and orders (88→21 lines) to use register_sx_layout with .sx defcomps
that read ctx values as free variables from the evaluation environment.
No more Python building SX strings via SxExpr(await root_header_sx(ctx)).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:39:53 +00:00
a84916e82f Fix filter/map tag disambiguation inside SVG context without keyword attrs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m19s
(filter (feTurbulence ...)) inside (svg ...) has no keyword first arg,
so the keyword-only check dispatched it as a HO function. Now also
check SVG/MathML context (ns in client, _svg_context in server).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:03:02 +00:00
f5c266e785 Fix custom element check: require keyword arg to disambiguate from variables
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m14s
Hyphenated names like app-url are variables, not custom elements.
Only treat as custom element when first arg is a Keyword (tag call pattern).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:55:36 +00:00
d551806976 Add SVG namespace auto-detection, custom elements, html: prefix, and fix filter/map tag collision
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- Fix filter/map dispatching as HO functions when used as SVG/HTML tags
  (peek at first arg — Keyword means tag call, not function call)
- Add html: prefix escape hatch to force any name to render as an element
- Support custom elements (hyphenated names) per Web Components spec
- SVG/MathML namespace auto-detection: client threads ns param through
  render chain; server uses _svg_context ContextVar so unknown tags
  inside (svg ...) or (math ...) render as elements without enumeration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:53:08 +00:00
2663dfb095 Add SVG cover art to SX Manifesto as s-expression
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m1s
Soviet constructivist poster with paper texture filters, grid lines,
aged stain spots, and "(<x>)" symbol in red.

Add missing SVG filter primitive tags to both server (html.py) and
client (sx.js): feTurbulence, feColorMatrix, feBlend,
feComponentTransfer, feFuncR/G/B/A, feDisplacementMap, feComposite,
feFlood, feImage, feMorphology, feSpecularLighting, feDiffuseLighting,
fePointLight, feSpotLight, feDistantLight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:34:58 +00:00
ccd9b969ea Add 40 more links to SX Manifesto, author: Markdown & Anglebrackets
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m26s
Link HTML, CSS, JavaScript, framework, separation of concerns, markup,
weak typing, prototype chains, declarative, Turing-complete, DOM,
homoiconicity, validate forms, server-side execution, signals, hooks,
reconciler, tree-shaking, code-splitting, HMR, transpilation, scoping,
composition model, template literals, inline styles, node_modules,
structured data, dependency tree, breaking changes, developer experience,
angle bracket, and "designed in ten days". Remove original source link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:27:43 +00:00
7325bb9ecf Fix ParseError in SX Manifesto: bare backslash + unclosed li
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m47s
- Wrap \"this\" in sx string quotes so backslash escapes are inside a string
- Remove stray quote before closing paren on wire protocol li item

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:15:09 +00:00
6f3562707a Change language to paradigm where referring to SX in the manifesto
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m37s
SX is a paradigm, not a language. Changed 7 instances where "language"
referred to SX itself: "one paradigm since 1958", "the paradigm is the
framework", "not a framework but a paradigm", "paradigms do not have
breaking changes", "the paradigm itself provides", "a paradigm that
does not require a migration guide", "distinct from the paradigm".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:00:02 +00:00
2609e782fc Change s-expressions to s-expressionism where it refers to the movement
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
In the SX Manifesto: "the spectre of s-expressionism", "S-expressionism
abolishes", "S-expressionism needs no ecosystem", "S-expressionism
resolves the CSS question", "The s-expressionist revolution".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:57:51 +00:00
28cbe60dc6 Add 78 links throughout the SX Manifesto essay
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
External links to Google, Meta, webpack, Vercel, Stack Overflow, React,
Vue, Angular, Svelte, SolidJS, Qwik, Astro, Next, Nuxt, Remix, Gatsby,
Rollup, Parcel, esbuild, Vite, Turbopack, TypeScript, Emacs, Clojure,
npm, Lisp, Scheme, Brendan Eich, Tailwind, Sass, Less, PostCSS, XML,
XSLT, JSON, YAML, TOML, JSX, SFCs, Lodash, Moment, Axios, left-pad,
is-odd, CSS-in-JS, virtual DOM, Vicar of Bray, CGI-bin, and Marx.

Internal links to /docs/components, /docs/evaluator, /docs/primitives,
/docs/css, /essays/on-demand-css, /protocols/wire-format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:55:12 +00:00
0f82294dc1 Update /docs/css page with CSSX native style primitives docs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m56s
Document css primitive, style atoms, variants, defstyle, defkeyframes,
and on-demand delivery protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:50:07 +00:00
19d59f5f4b Implement CSSX Phase 2: native SX style primitives
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Replace Tailwind class strings with native SX expressions:
(css :flex :gap-4 :hover:bg-sky-200) instead of :class "flex gap-4 ..."

- Add style_dict.py: 516 atoms, variants, breakpoints, keyframes, patterns
- Add style_resolver.py: memoized resolver with variant splitting
- Add StyleValue type to types.py (frozen dataclass with class_name, declarations, etc.)
- Add css and merge-styles primitives to primitives.py
- Add defstyle and defkeyframes special forms to evaluator.py and async_eval.py
- Integrate StyleValue into html.py and async_eval.py render paths
- Add register_generated_rule() to css_registry.py, fix media query selector
- Add style dict JSON delivery with localStorage caching to helpers.py
- Add client-side css primitive, resolver, and style injection to sx.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:47:51 +00:00
28388540d5 Fix unquoted (code) element in SX manifesto essay
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34s
The (code :class "text-violet-700" ...) was embedded inside a string
child of (p), causing the SX parser to see text-violet-700 as a bare
symbol. Close the text string before the (code) element so it becomes
a proper child of the paragraph.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:09:41 +00:00
5fac47c132 Fix sx-manifesto EvalError: close string before italic span
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m40s
The em span variable was embedded inside an unclosed sx string,
causing the " before "italic" to close the outer string and
leaving italic as an undefined bare symbol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:57:28 +00:00
213421516e Add SSE, response headers, view transitions, and 5 new sx attributes
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Implement missing SxEngine features:
- SSE (sx-sse, sx-sse-swap) with EventSource management and auto-cleanup
- Response headers: SX-Trigger, SX-Retarget, SX-Reswap, SX-Redirect,
  SX-Refresh, SX-Location, SX-Replace-Url, SX-Trigger-After-Swap/Settle
- View Transitions API: transition:true swap modifier + global config
- every:<time> trigger for polling (setInterval)
- sx-replace-url (replaceState instead of pushState)
- sx-disabled-elt (disable elements during request)
- sx-prompt (window.prompt, value sent as SX-Prompt header)
- sx-params (filter form parameters: *, none, not x,y, x,y)

Adds docs (ATTR_DETAILS, BEHAVIOR_ATTRS, headers, events), demo
components in reference.sx, API endpoints (prompt-echo, sse-time),
and 27 new unit tests for engine logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:55:21 +00:00
3bffc212cc Fix sx docs load_sx_dir path to point to sxc/ not sxc/pages/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 33s
The .sx component files (home.sx, docs.sx, etc.) live in sxc/, but
the path was pointing to sxc/pages/ after the move from sx_components.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:35:22 +00:00
b51b050dda Delete last sx_components.py files: relations + test (phase 9)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m2s
Move relations component loading into app.py. Move test rendering
functions to test/sxc/pages/__init__.py, update route imports, and
delete both sx_components.py files. Zero sx_components imports remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:26:58 +00:00
5bb02b7dd5 Move 7 htmx-equivalent attrs from SX_UNIQUE_ATTRS to BEHAVIOR_ATTRS
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
These are sx implementations of htmx attributes (boost, preload,
preserve, indicator, validate, ignore, optimistic), not unique to sx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:26:24 +00:00
16f0908ec9 Move SX docs rendering from sx_components.py to sxc/pages (phase 8)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 33s
Consolidate 86 component rendering functions into sxc/pages/__init__.py,
update 37 import sites in routes.py, remove app.py side-effect imports,
and delete sx/sxc/sx_components.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:24:26 +00:00
7419ecf3c0 Delete events sx_components.py — move all rendering to sxc/pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Phase 7 of the zero-Python-rendering plan. All 100 rendering functions
move from events/sx/sx_components.py into events/sxc/pages/__init__.py.
Route handlers (15 files) import from sxc.pages instead.
load_service_components call moves into _load_events_page_files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:19:38 +00:00
31a8b755d9 Implement 7 missing sx attributes: boost, preload, preserve, indicator, validate, ignore, optimistic
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Add sx-preserve/sx-ignore (morph skip), sx-indicator (loading element),
sx-validate (form validation), sx-boost (progressive enhancement),
sx-preload (hover prefetch with 30s cache), and sx-optimistic (instant
UI preview with rollback). Move all from HTMX_MISSING_ATTRS to
SX_UNIQUE_ATTRS with full ATTR_DETAILS docs and reference.sx demos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:18:31 +00:00
049796c391 Delete market sx_components.py — move all rendering to sxc/pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 32s
Phase 6 of the zero-Python-rendering plan. All 46 rendering functions
move from market/sx/sx_components.py into market/sxc/pages/__init__.py.
Route handlers import from sxc.pages instead. load_service_components
call moves into _load_market_page_files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:08:44 +00:00
8578eb525e Change sx logo to (<x>)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:56:50 +00:00
96a4f56424 Fix extra closing paren in SX manifesto and TCO essays
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Both essays had one excess `)` causing ParseError when navigating
via HTMX (oob_page_sx path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:55:51 +00:00
e72f7485f4 Add TCO trampolining to async evaluator and sx.js client
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Both evaluators now use thunk-based trampolining to eliminate stack
overflow on deep tail recursion (verified at 50K+ depth). Mirrors
the sync evaluator TCO added in 5069072.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:53:16 +00:00
da8d2e342f Continuations essay: add argument that they're easier to reason about than workarounds
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m11s
Without call/cc you get callback pyramids, state machines, command
pattern undo stacks, Promise chains, and framework-specific hooks —
each a partial ad-hoc reinvention of continuations with its own edge
cases. The complexity doesn't disappear; it moves into user code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:08 +00:00
fd67f202c2 Add Continuations essay to SX docs
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Covers server-side (suspendable rendering, streaming, error boundaries),
client-side (linear async flows, wizard forms, cooperative scheduling,
undo), and implementation path from the existing TCO trampoline. Updates
TCO essay's continuations section to link to the new essay instead of
dismissing the idea. Fixes "What sx is not" to acknowledge macros + TCO.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:41:22 +00:00
5069072715 Add TCO to evaluator, update SX docs messaging
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m3s
Evaluator: add _Thunk + _trampoline for tail-call optimization in
lambdas, components, if/when/cond/case/let/begin. All callers in
html.py, resolver.py, handlers.py, pages.py, jinja_bridge.py, and
query_registry.py unwrap thunks at non-tail positions.

SX docs: update tagline to "s-expressions for the web", rewrite intro
to reflect that SX replaces most JavaScript need, fix "What sx is not"
to acknowledge macros and TCO exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:31:31 +00:00
a3318b4fd7 Fix syntax error: double-escaped apostrophes in essay strings
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:23:05 +00:00
8a945db37b Add "The SX Manifesto" essay to sx docs app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
A satirical essay in the style of The Communist Manifesto, recasting
the historic struggle between bourgeoisie and proletariat as the war
between HTML, JS, and CSS — with frameworks as petty-bourgeois lackeys
and s-expressions as the revolutionary force that abolishes the
language distinction itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:11:59 +00:00
03f9968979 Add @ rules, dynamic generation, and arbitrary values to SX styles plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m0s
Cover @keyframes (defkeyframes special form + built-in animations),
@container queries, dynamic atom construction (no server round-trip
since client has full dictionary), arbitrary bracket values (w-[347px]),
and inline style fallback for truly unique data-driven values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:05:25 +00:00
96132d9cfe Add Phase 2 SX styles plan to cssx.md
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
Document the design for native s-expression style primitives
(css :flex :gap-4 ...) to replace Tailwind CSS strings with first-class
SX expressions. Covers style dictionary, resolver, delivery/caching
(localStorage like components), server-side session tracking, and
migration tooling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:00:54 +00:00
baf9f1468d Fix services.get() → services.blog_page attribute access
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
The service registry uses __getattr__, so .get() is interpreted
as looking up a service named "get". Use attribute access instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:57:04 +00:00
c2fe142039 Delete blog sx_components.py — move all rendering to callers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
Move remaining 19 rendering functions from the 2487-line
sx_components.py to their direct callers:

- menu_items/routes.py: menu item form, page search, nav OOB
- post/admin/routes.py: calendar view, associated entries, nav OOB
- sxc/pages/__init__.py: editor panel, post data inspector, preview,
  entries browser, settings form, edit page editor
- bp/blog/routes.py: inline new post page composition

Move load_service_components() call from sx_components module-level
to setup_blog_pages() so .sx files still load at startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:43:52 +00:00
f0fbcef3f6 Inline header functions from sx_components into pages/__init__.py
Move _blog_header_sx, _settings_header_sx, _settings_nav_sx, and
_sub_settings_header_sx into the layout module as local helpers.
Eliminates 14 imports from sx_components.py for the layout system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:27:31 +00:00
d7f9afff8e Move home/post detail/like rendering from Python to .sx defcomps
- Home page: inline shared helpers, render_to_sx("blog-home-main")
- Post detail: new ~blog-post-detail-content defcomp with data from service
- Like toggle: call render_to_sx("market-like-toggle-button") directly
- Add post_meta_data() and post_detail_data() to BlogPageService

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:24:55 +00:00
f2910ad767 Replace fragment render functions with .sx defcomps
- Snippets list: render_snippets_list → render_to_sx("blog-snippets-content")
- Menu items list: render_menu_items_list → _render_menu_items_list helper
- Features panel: render_features_panel → render_to_sx("blog-features-panel-content")
- Markets panel: render_markets_panel → render_to_sx("blog-markets-panel-content")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:20:47 +00:00
e75c8d16d1 Move blog index rendering from Python to .sx composition defcomps
BlogPageService.index_data() assembles all data (cards, filters, actions)
and 7 new .sx defcomps handle rendering: main content, aside, filter,
actions, tag groups filter, authors filter, and sentinel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:14:23 +00:00
984e2ebed0 Fix cart load_service_components: use os.path instead of Path
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
Avoid UnboundLocalError with Path by using os.path directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:57:27 +00:00
d80894dbf5 Fix cart load_service_components path
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The old sx_components.py used os.path.dirname(__file__) to resolve
the app root. When it was deleted, the replacement call in app.py
used the string "cart" which resolves to /app/cart/ (alembic only),
not /app/ where the sx/ directory lives. Use Path(__file__).parent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:56:04 +00:00
8e16cc459a Fix Like model import path in SqlLikesService
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
Inside the likes container the model is at models.like not
likes.models.like — the container's Python path is /app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:52:51 +00:00
336a4ad9a1 Lazy-import Like model in SqlLikesService
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
The module-level import of likes.models.like.Like caused ImportError
in non-likes services that register SqlLikesService. Move the import
into a lazy helper called per-method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:51:11 +00:00
d6f3250a77 Fix dev_watcher sentinel path for container permissions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
The sentinel was written to shared/_reload_sentinel.py but shared/ is
volume-mounted as root:root, so appuser can't create files there.
Move sentinel to /app/_reload_sentinel.py which is owned by appuser
and still under Hypercorn's --reload watch path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:37:29 +00:00
486ab834de Fix datetime serialization in _dto_to_dict
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
Use dto_to_dict() from shared/contracts/dtos.py for dataclass
serialization instead of raw dataclasses.asdict(). This ensures
datetimes are converted to ISO format strings (not RFC 2822 from
jsonify), matching what dto_from_dict() expects on the receiving end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:28:47 +00:00
41e803335a Fix _dto_to_dict for slots=True dataclasses
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s
The defquery conversion routes inter-service results through
_dto_to_dict which checked __dict__ (absent on slots dataclasses),
producing {"value": obj} instead of proper field dicts. This broke
TicketDTO deserialization in the cart app. Check __dataclass_fields__
first and use dataclasses.asdict() for correct serialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:24:40 +00:00
1f36987f77 Replace inter-service _handlers dicts with declarative sx defquery/defaction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m5s
The inter-service data layer (fetch_data/call_action) was the least
structured part of the codebase — Python _handlers dicts with ad-hoc
param extraction scattered across 16 route files. This replaces them
with declarative .sx query/action definitions that make the entire
inter-service protocol self-describing and greppable.

Infrastructure:
- defquery/defaction special forms in the sx evaluator
- Query/action registry with load, lookup, and schema introspection
- Query executor using async_eval with I/O primitives
- Blueprint factories (create_data_blueprint/create_action_blueprint)
  with sx-first dispatch and Python fallback
- /internal/schema endpoint on every service
- parse-datetime and split-ids primitives for type coercion

Service extractions:
- LikesService (toggle, is_liked, liked_slugs, liked_ids)
- PageConfigService (ensure, get_by_container, get_by_id, get_batch, update)
- RelationsService (wraps module-level functions)
- AccountDataService (user_by_email, newsletters)
- CartItemsService, MarketDataService (raw SQLAlchemy lookups)

50 of 54 handlers converted to sx, 4 Python fallbacks remain
(ghost-sync/push-member, clear-cart-for-order, create-order).
Net: -1,383 lines Python, +251 lines modified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:13:50 +00:00
e53e8cc1f7 Eliminate blog settings page helpers — pure .sx defpages with service data
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s
Convert 6 blog settings pages (settings-home, cache, snippets, menu-items,
tag-groups, tag-group-edit) from Python page helpers to .sx defpages with
(service "blog-page" ...) IO primitives. Create data-driven defcomps that
handle iteration via (map ...) instead of Python loops.

Post-related page helpers (editor, post-admin/data/preview/entries/settings/edit)
remain as Python helpers — they depend on _ensure_post_data and sx_components
rendering functions that need separate conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:50:24 +00:00
418ac9424f Eliminate Python page helpers from account, federation, and cart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m8s
All three services now fetch page data via (service ...) IO primitives
in .sx defpages instead of Python middleman functions.

- Account: newsletters-data → AccountPageService.newsletters_data
- Federation: 8 page helpers → FederationPageService methods
  (timeline, compose, search, following, followers, notifications)
- Cart: 4 page helpers → CartPageService methods
  (overview, page-cart, admin, payments)
- Serializers moved to service modules, thin delegates kept for routes
- ~520 lines of Python page helpers removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:01:50 +00:00
fb8f115acb Fix orders defpage: length→len primitive, handle _RawHTML in serialize()
- Fix undefined symbol 'length' → use 'len' primitive in orders.sx
- Add _RawHTML handling in serialize() — wraps as (raw! "...") for SX wire format
  instead of falling through to repr() which produced unparseable symbol names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:55:32 +00:00
63b895afd8 Eliminate Python page helpers from orders — pure .sx defpages with IO primitives
Orders defpages now fetch data via (service ...) and generate URLs via
(url-for ...) and (route-prefix) directly in .sx. No Python middleman.

- Add url-for, route-prefix IO primitives to shared/sx/primitives_io.py
- Add generic register()/\_\_getattr\_\_ to ServiceRegistry for dynamic services
- Create OrdersPageService with list_page_data/detail_page_data methods
- Rewrite orders.sx defpages to use IO primitives + defcomp calls
- Remove ~320 lines of Python page helpers from orders/sxc/pages/__init__.py
- Convert :data env merge to use kebab-case keys for SX symbol access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:50:15 +00:00
50b33ab08e Fix page helper results being quoted as string literals in defpage slots
Page helpers return SX source strings from render_to_sx(), but _aser's
serialize() was wrapping them in double quotes. In async_eval_slot_to_sx,
pass string results through directly since they're already SX source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:43:00 +00:00
bd314a0be7 Guard against empty SxExpr in _as_sx and _build_component_ast
Fragment responses with text/sx content-type but empty body create
SxExpr(""), which is truthy but fails to parse. Handle this by
returning None from _as_sx for empty SxExpr sources, and treating
empty SxExpr as NIL in _build_component_ast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:37:27 +00:00
41cdd6eab8 Add sxc/ volume mounts to docker-compose.dev.yml for all services
The sxc/ directories (defpages, layouts, page helpers) were not
bind-mounted, so dev containers used stale code from the Docker image.
This caused the orders.defpage_order_detail BuildError since the
container had old sxc/pages/__init__.py without the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:34:14 +00:00
1a6503782d Phase 4: Delete cart/sx/sx_components.py, move renders to sxc/pages
Move all render functions (orders page/rows/oob, order detail/oob,
checkout error, payments panel), header helpers, and serializers from
cart/sx/sx_components.py into cart/sxc/pages/__init__.py. Update all
route imports from sx.sx_components to sxc.pages. Replace
import sx.sx_components in app.py with load_service_components("cart").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:30:30 +00:00
72997068c6 Fix orders defpage endpoint references — app-level not blueprint
defpages mounted via auto_mount_pages() register endpoints without
blueprint prefix. Fix url_for("orders.defpage_*") → url_for("defpage_*").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:24:14 +00:00
dacb61b0ae Delete orders + federation sx_components.py — rendering inlined to routes
Phase 2 (Orders):
- Checkout error/return renders moved directly into route handlers
- Removed orphaned test_sx_helpers.py

Phase 3 (Federation):
- Auth pages use _render_social_auth_page() helper in routes
- Choose-username render inlined into identity routes
- Timeline/search/follow/interaction renders inlined into social routes
  using serializers imported from sxc.pages
- Added _social_page() to sxc/pages/__init__.py for shared use
- Home page renders inline in app.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:22:33 +00:00
400667b15a Delete account/sx/sx_components.py — all rendering now in .sx
Phase 1 of zero-Python rendering: account service.

- Auth pages (login, device, check-email) use _render_auth_page() helper
  calling render_to_sx() + full_page_sx() directly in routes
- Newsletter toggle POST renders inline via render_to_sx()
- Newsletter page helper returns data dict; defpage :data slot fetches,
  :content slot renders via ~account-newsletters-content defcomp
- Fragment page uses (frag ...) IO primitive directly in .sx
- Defpage _eval_slot now uses async_eval_slot_to_sx which expands
  component bodies server-side (executing IO) but serializes tags as SX
- Fix pre-existing OOB ParseError: _eval_slot was producing HTML instead
  of s-expressions for component content slots
- Fix market url_for endpoint: defpage_market_home (app-level, not blueprint)
- Fix events calendar nav: wrap multiple SX parts in fragment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:16:01 +00:00
44503a7d9b Add Client Reactivity and SX Native essays to sx docs app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:11:48 +00:00
e085fe43b4 Replace sx_call() with render_to_sx() across all services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Python no longer generates s-expression strings. All SX rendering now
goes through render_to_sx() which builds AST from native Python values
and evaluates via async_eval_to_sx() — no SX string literals in Python.

- Add render_to_sx()/render_to_html() infrastructure in shared/sx/helpers.py
- Add (abort status msg) IO primitive in shared/sx/primitives_io.py
- Convert all 9 services: ~650 sx_call() invocations replaced
- Convert shared helpers (root_header_sx, full_page_sx, etc.) to async
- Fix likes service import bug (likes.models → models)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:08:33 +00:00
0554f8a113 Refactor sx.js: extract string renderer, deduplicate helpers, remove dead code
Extract Node-only string renderer (renderToString, renderStr, etc.) to
sx-test.js. Add shared helpers (_processOOBSwaps, _postSwap, _processBindings,
_evalCond, _logParseError) replacing duplicated logic. Remove dead isTruthy
and _sxCssKnown class-list fallback. Compress section banners. sx.js goes
from 2652 to 2279 lines (-14%) with zero browser-side behavior change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:00:58 +00:00
4e5f9ff16c Remove dead render_profile_page from federation sx_components
This function was replaced by defpage-based rendering but never deleted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:41:19 +00:00
193578ef88 Move SX construction from Python to .sx defcomps (phases 0-4)
Eliminate Python s-expression string building across account, orders,
federation, and cart services. Visual rendering logic now lives entirely
in .sx defcomp components; Python files contain only data serialization,
header/layout wiring, and thin wrappers that call defcomps.

Phase 0: Shared DRY extraction — auth/orders header defcomps, format-decimal/
pluralize/escape/route-prefix primitives.
Phase 1: Account — dashboard, newsletters, login/device/check-email content.
Phase 2: Orders — order list, detail, filter, checkout return assembled defcomps.
Phase 3: Federation — social nav, post cards, timeline, search, actors,
notifications, compose, profile assembled defcomps.
Phase 4: Cart — overview, page cart items/calendar/tickets/summary, admin,
payments assembled defcomps; orders rendering reuses Phase 2 shared defcomps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:36:34 +00:00
03f0929fdf Fix SX nav morphing, retry error modal, and aria-selected CSS extraction
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m18s
- Re-read verb URL from element attributes at execution time so morphed
  nav links navigate to the correct destination
- Reset retry backoff on fresh requests; skip error modal when sx-retry
  handles the failure
- Strip attribute selectors in CSS registry so aria-selected:* classes
  resolve correctly for on-demand CSS
- Add @css annotations for dynamic aria-selected variant classes
- Add SX docs integration test suite (102 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:37:17 +00:00
f551fc7453 Convert last Python fragment handlers to SX defhandlers: 100% declarative fragment API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34m5s
- Add dict recursion to _convert_result for service methods returning dict[K, list[DTO]]
- New container-cards.sx: parses post_ids/slugs, calls confirmed-entries-for-posts, emits card-widget markers
- New account-page.sx: dispatches on slug for tickets/bookings panels with status pills and empty states
- Fix blog _parse_card_fragments to handle SxExpr via str() cast
- Remove events Python fragment handlers and simplify app.py to plain auto_mount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:42:19 +00:00
e30cb0a992 Auto-mount fragment handlers: eliminate fragment blueprint boilerplate across all 8 services
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 16m38s
Fragment read API is now fully declarative — every handler is a defhandler
s-expression dispatched through one shared auto_mount_fragment_handlers()
function. Replaces 8 near-identical blueprint files (~35 lines each) with
a single function call per service. Events Python handlers (container-cards,
account-page) extracted to a standalone module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:13:15 +00:00
293f7713d6 Auto-mount defpages: eliminate Python route stubs across all 9 services
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 16s
Defpages are now declared with absolute paths in .sx files and auto-mounted
directly on the Quart app, removing ~850 lines of blueprint mount_pages calls,
before_request hooks, and g.* wrapper boilerplate. A new page = one defpage
declaration, nothing else.

Infrastructure:
- async_eval awaits coroutine results from callable dispatch
- auto_mount_pages() mounts all registered defpages on the app
- g._defpage_ctx pattern passes helper data to layout context

Migrated: sx, account, orders, federation, cart, market, events, blog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:03:15 +00:00
4ba63bda17 Add server-driven architecture principle and React feature analysis
Documents why sx stays server-driven by default, maps React features
to sx equivalents, and defines targeted escape hatches for the few
interactions that genuinely need client-side state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:48:35 +00:00
0a81a2af01 Convert social and federation profile from Jinja to SX rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 14m34s
Add primitives (replace, strip-tags, slice, csrf-token), convert all
social blueprint routes and federation profile to SX content builders,
delete 12 unused Jinja templates and social_lite layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:43:47 +00:00
0c9dbd6657 Add attribute detail pages with live demos for SX reference
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m45s
Per-attribute documentation pages at /reference/attributes/<slug> with:
- Live interactive demos (demo components in reference.sx)
- S-expression source code display
- Server handler code shown as s-expressions (defhandlers in handlers/reference.sx)
- Wire response display via OOB swaps on demo interaction
- Linked attribute names in the reference table

Covers all 20 implemented attributes (sx-get/post/put/delete/patch,
sx-trigger/target/swap/swap-oob/select/confirm/push-url/sync/encoding/
headers/include/vals/media/disable/on:*, sx-retry, data-sx, data-sx-env).

Also adds sx-on:* to BEHAVIOR_ATTRS, updates REFERENCE_NAV to link
/reference/attributes, and makes /reference/ an index page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:12:57 +00:00
a4377668be Add isomorphic SX architecture migration plan
Documents the 5-phase plan for making the sx s-expression layer a
universal view language that renders on either client or server, with
pages as cached components and data-only navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:52:12 +00:00
a98354c0f0 Fix duplicate headers on HTMX nav, editor content loading, and double mount
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m14s
- Nest admin header inside post-header-child (layouts.py/helpers.py) so
  full-page DOM matches OOB swap structure, eliminating duplicate headers
- Clear post-header-child on post layout OOB to remove stale admin rows
- Read SX initial content from #sx-content-input instead of
  window.__SX_INITIAL__ to avoid escaping issues through SX pipeline
- Fix client-side SX parser RE_STRING to handle escaped newlines
- Clear root element in SxEditor.mount() to prevent double content on
  HTMX re-mount
- Remove unused ~blog-editor-sx-initial component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:27:47 +00:00
df8b19ccb8 Convert post edit form from raw HTML to SX expressions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m29s
Replace _post_edit_content_sx raw HTML builder with sx_call() pattern
matching render_editor_panel. Add ~blog-editor-edit-form,
~blog-editor-publish-js, ~blog-editor-sx-initial components to
editor.sx. Fixes (~sx-editor-styles) rendering as literal text on
the edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:53:50 +00:00
238 changed files with 23016 additions and 17241 deletions

4
account/actions.sx Normal file
View File

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

View File

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

View File

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

View File

@@ -7,17 +7,13 @@ from __future__ import annotations
from quart import (
Blueprint,
request,
redirect,
g,
)
from sqlalchemy import select
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
from shared.infrastructure.fragments import fetch_fragments
from shared.sx.helpers import sx_response, render_to_sx
def register(url_prefix="/"):
@@ -25,8 +21,7 @@ def register(url_prefix="/"):
@account_bp.before_request
async def _prepare_page_data():
"""Fetch account_nav fragments and load data for defpage routes."""
# Fetch account nav items for layout (was in context_processor)
"""Fetch account_nav fragments for layout."""
events_nav, cart_nav, artdag_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
@@ -34,48 +29,6 @@ def register(url_prefix="/"):
], required=False)
g.account_nav = events_nav + cart_nav + artdag_nav
if request.method != "GET":
return
endpoint = request.endpoint or ""
# Newsletters page — load newsletter data
if endpoint.endswith("defpage_newsletters"):
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
g.newsletters_data = newsletter_list
# Fragment page — load fragment from events service
elif endpoint.endswith("defpage_fragment_page"):
slug = request.view_args.get("slug")
if slug and g.get("user"):
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
from quart import abort
abort(404)
g.fragment_page_data = fragment_html
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
if not g.get("user"):
@@ -101,7 +54,26 @@ def register(url_prefix="/"):
await g.s.flush()
from sx.sx_components import render_newsletter_toggle
return sx_response(render_newsletter_toggle(un))
# Render toggle directly — no sx_components intermediary
from shared.browser.app.csrf import generate_csrf_token
from shared.infrastructure.urls import account_url
nid = un.newsletter_id
url_fn = getattr(g, "_account_url", None) or account_url
toggle_url = url_fn(f"/newsletter/{nid}/toggle/")
csrf = generate_csrf_token()
bg = "bg-emerald-500" if un.subscribed else "bg-stone-300"
translate = "translate-x-6" if un.subscribed else "translate-x-1"
checked = "true" if un.subscribed else "false"
return sx_response(await render_to_sx(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
))
return account_bp

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Account app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``account/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("account", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "account", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

9
account/queries.sx Normal file
View File

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

View File

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

View File

@@ -0,0 +1,40 @@
"""Account page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
class AccountPageService:
"""Service for account page data, callable via (service "account-page" ...)."""
async def newsletters_data(self, session, **kw):
"""Return newsletter list with user subscription status."""
from quart import g
from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
result = await session.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
sub_result = await session.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": {"id": nl.id, "name": nl.name, "description": nl.description},
"un": {"newsletter_id": un.newsletter_id, "subscribed": un.subscribed} if un else None,
"subscribed": un.subscribed if un else False,
})
from shared.infrastructure.urls import account_url
return {
"newsletter_list": newsletter_list,
"account_url": account_url(""),
}

View File

@@ -27,3 +27,25 @@
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
;; Assembled auth page content — replaces Python _login_page_content etc.
(defcomp ~account-login-content (&key error email)
(~auth-login-form
:error (when error (~auth-error-banner :error error))
:action (url-for "auth.start_login")
:csrf-token (csrf-token)
:email (or email "")))
(defcomp ~account-device-content (&key error code)
(~account-device-form
:error (when error (~account-device-error :error error))
:action (url-for "auth.device_submit")
:csrf-token (csrf-token)
:code (or code "")))
(defcomp ~account-check-email-content (&key email email-error)
(~auth-check-email
:email (escape (or email ""))
:error (when email-error
(~auth-check-email-error :error (escape email-error)))))

View File

@@ -41,3 +41,20 @@
name)
logout)
labels)))
;; Assembled dashboard content — replaces Python _account_main_panel_sx
(defcomp ~account-dashboard-content (&key error)
(let* ((user (current-user))
(csrf (csrf-token)))
(~account-main-panel
:error (when error (~account-error-banner :error error))
:email (when (get user "email")
(~account-user-email :email (get user "email")))
:name (when (get user "name")
(~account-user-name :name (get user "name")))
:logout (~account-logout-form :csrf-token csrf)
:labels (when (not (empty? (or (get user "labels") (list))))
(~account-labels-section
:items (map (lambda (label)
(~account-label-item :name (get label "name")))
(get user "labels")))))))

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

@@ -0,0 +1,27 @@
;; Account layout defcomps — read ctx values from env free variables.
;; Registered via register_sx_layout("account", ...) in __init__.py.
;; Full page: root header + auth header row in header-child
(defcomp ~account-layout-full ()
(<> (~root-header)
(~header-child-sx
:inner (~auth-header-row :account-url account-url
:select-colours select-colours
:account-nav account-nav))))
;; OOB (HTMX): auth row + root header, both with oob=true
(defcomp ~account-layout-oob ()
(<> (~auth-header-row :account-url account-url
:select-colours select-colours
:account-nav account-nav
:oob true)
(~root-header :oob true)))
;; Mobile menu: auth section + root nav
(defcomp ~account-layout-mobile ()
(<> (~mobile-menu-section
:label "account" :href "/" :level 1 :colour "sky"
:items (~auth-nav-items :account-url account-url
:select-colours select-colours
:account-nav account-nav))
(~root-mobile)))

View File

@@ -29,3 +29,34 @@
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
list)))
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
;; Takes pre-fetched newsletter-list from page helper
(defcomp ~account-newsletters-content (&key newsletter-list account-url)
(let* ((csrf (csrf-token)))
(if (empty? newsletter-list)
(~account-newsletter-empty)
(~account-newsletters-panel
:list (~account-newsletter-list
:items (map (lambda (item)
(let* ((nl (get item "newsletter"))
(un (get item "un"))
(nid (get nl "id"))
(subscribed (get item "subscribed"))
(toggle-url (str (or account-url "") "/newsletter/" nid "/toggle/"))
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
(translate (if subscribed "translate-x-6" "translate-x-1"))
(checked (if subscribed "true" "false")))
(~account-newsletter-item
:name (get nl "name")
:desc (when (get nl "description")
(~account-newsletter-desc :description (get nl "description")))
:toggle (~account-newsletter-toggle
:id (str "nl-" nid)
:url toggle-url
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
:target (str "#nl-" nid)
:cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg)
:checked checked
:knob-cls (str "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform " translate)))))
newsletter-list))))))

View File

@@ -1,339 +0,0 @@
"""
Account service s-expression page components.
Renders account dashboard, newsletters, fragment pages, login, and device
auth pages. Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations
import os
from typing import Any
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, sx_call, SxExpr,
root_header_sx, full_page_sx,
)
# Load account-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="account")
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _auth_nav_sx(ctx: dict) -> str:
"""Auth section desktop nav items."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
nav=SxExpr(_auth_nav_sx(ctx)),
child_id="auth-header-child", oob=oob,
)
def _auth_nav_mobile_sx(ctx: dict) -> str:
"""Mobile nav menu for auth section."""
parts = [
sx_call("nav-link",
href=call_url(ctx, "account_url", "/newsletters/"),
label="newsletters",
select_colours=ctx.get("select_colours", ""),
)
]
account_nav = ctx.get("account_nav")
if account_nav:
parts.append(account_nav)
return "(<> " + " ".join(parts) + ")"
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# ---------------------------------------------------------------------------
def _account_main_panel_sx(ctx: dict) -> str:
"""Account info panel with user details and logout."""
from quart import g
from shared.browser.app.csrf import generate_csrf_token
user = getattr(g, "user", None)
error = ctx.get("error", "")
error_sx = sx_call("account-error-banner", error=error) if error else ""
user_email_sx = ""
user_name_sx = ""
if user:
user_email_sx = sx_call("account-user-email", email=user.email)
if user.name:
user_name_sx = sx_call("account-user-name", name=user.name)
logout_sx = sx_call("account-logout-form", csrf_token=generate_csrf_token())
labels_sx = ""
if user and hasattr(user, "labels") and user.labels:
label_items = " ".join(
sx_call("account-label-item", name=label.name)
for label in user.labels
)
labels_sx = sx_call("account-labels-section",
items=SxExpr("(<> " + label_items + ")"))
return sx_call(
"account-main-panel",
error=SxExpr(error_sx) if error_sx else None,
email=SxExpr(user_email_sx) if user_email_sx else None,
name=SxExpr(user_name_sx) if user_name_sx else None,
logout=SxExpr(logout_sx),
labels=SxExpr(labels_sx) if labels_sx else None,
)
# ---------------------------------------------------------------------------
# Newsletters (GET /newsletters/)
# ---------------------------------------------------------------------------
def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
"""Render a single newsletter toggle switch."""
nid = un.newsletter_id
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
if un.subscribed:
bg = "bg-emerald-500"
translate = "translate-x-6"
checked = "true"
else:
bg = "bg-stone-300"
translate = "translate-x-1"
checked = "false"
return sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
target=f"#nl-{nid}",
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
checked=checked,
knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}",
)
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
return sx_call(
"account-newsletter-toggle",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
target=f"#nl-{nid}",
cls="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300",
checked="false",
knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1",
)
def _newsletters_panel_sx(ctx: dict, newsletter_list: list) -> str:
"""Newsletters management panel."""
from shared.browser.app.csrf import generate_csrf_token
account_url_fn = ctx.get("account_url") or (lambda p: p)
csrf = generate_csrf_token()
if newsletter_list:
items = []
for item in newsletter_list:
nl = item["newsletter"]
un = item.get("un")
desc_sx = sx_call(
"account-newsletter-desc", description=nl.description
) if nl.description else ""
if un:
toggle = _newsletter_toggle_sx(un, account_url_fn, csrf)
else:
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
toggle = _newsletter_toggle_off_sx(nl.id, toggle_url, csrf)
items.append(sx_call(
"account-newsletter-item",
name=nl.name,
desc=SxExpr(desc_sx) if desc_sx else None,
toggle=SxExpr(toggle),
))
list_sx = sx_call(
"account-newsletter-list",
items=SxExpr("(<> " + " ".join(items) + ")"),
)
else:
list_sx = sx_call("account-newsletter-empty")
return sx_call("account-newsletters-panel", list=SxExpr(list_sx))
# ---------------------------------------------------------------------------
# Auth pages (login, device, check_email)
# ---------------------------------------------------------------------------
def _login_page_content(ctx: dict) -> str:
"""Login form content."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
email = ctx.get("email", "")
action = url_for("auth.start_login")
error_sx = sx_call("auth-error-banner", error=error) if error else ""
return sx_call(
"auth-login-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), email=email,
)
def _device_page_content(ctx: dict) -> str:
"""Device authorization form content."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
code = ctx.get("code", "")
action = url_for("auth.device_submit")
error_sx = sx_call("account-device-error", error=error) if error else ""
return sx_call(
"account-device-form",
error=SxExpr(error_sx) if error_sx else None,
action=action,
csrf_token=generate_csrf_token(), code=code,
)
def _device_approved_content() -> str:
"""Device approved success content."""
return sx_call("account-device-approved")
# ---------------------------------------------------------------------------
# Public API: Account dashboard
# ---------------------------------------------------------------------------
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device)
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Check email page (POST /start/ success)
# ---------------------------------------------------------------------------
def _check_email_content(email: str, email_error: str | None = None) -> str:
"""Check email confirmation content."""
from markupsafe import escape
error_sx = sx_call(
"auth-check-email-error", error=str(escape(email_error))
) if email_error else ""
return sx_call(
"auth-check-email",
email=str(escape(email)),
error=SxExpr(error_sx) if error_sx else None,
)
async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
hdr = root_header_sx(ctx)
return full_page_sx(ctx, header_rows=hdr,
content=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response (uses account_url)."""
from shared.browser.app.csrf import generate_csrf_token
from quart import g
account_url_fn = getattr(g, "_account_url", None)
if account_url_fn is None:
from shared.infrastructure.urls import account_url
account_url_fn = account_url
return _newsletter_toggle_sx(un, account_url_fn, generate_csrf_token())
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _sx_escape(s: str) -> str:
"""Escape a string for embedding in sx string literals."""
return s.replace("\\", "\\\\").replace('"', '\\"')

View File

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

View File

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

12
blog/actions.sx Normal file
View File

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

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -16,7 +15,6 @@ from bp import (
register_admin,
register_menu_items,
register_snippets,
register_fragments,
register_data,
register_actions,
)
@@ -108,7 +106,9 @@ def create_app() -> "Quart":
app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "blog")
app.register_blueprint(register_data())
app.register_blueprint(register_actions())
@@ -162,6 +162,23 @@ def create_app() -> "Quart":
)
return jsonify(resp)
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "blog")
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_blog_data():
import os
from shared.config import config as get_config
ctx = {
"blog_title": get_config()["blog_title"],
"base_title": get_config()["title"],
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
ctx.update(getattr(g, '_defpage_ctx', {}))
return ctx
# --- debug: url rules ---
@app.get("/__rules")
async def dump_rules():

View File

@@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp
from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .fragments import register_fragments
from .data import register_data
from .actions.routes import register as register_actions

View File

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

View File

@@ -3,13 +3,9 @@ from __future__ import annotations
#from quart import Blueprint, g
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
jsonify
)
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
@@ -27,23 +23,6 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings",
}
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_settings_home" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
g.settings_content = _settings_main_panel_sx(tctx)
elif "defpage_cache_page" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
g.cache_content = _cache_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
@bp.post("/cache_clear/")
@require_admin
async def cache_clear():
@@ -54,7 +33,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html)
return redirect(url_for("settings.defpage_cache_page"))
return redirect(url_for("defpage_cache_page"))
return bp

View File

@@ -2,8 +2,6 @@ from __future__ import annotations
import re
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
@@ -13,9 +11,7 @@ from quart import (
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
@@ -46,60 +42,13 @@ async def _unassigned_tags(session):
def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_tag_groups_page" in ep:
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
elif "defpage_tag_group_edit" in ep:
tag_id = (request.view_args or {}).get("id")
tg = await g.s.get(TagGroup, tag_id)
if not tg:
from quart import abort
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
)).scalars()
)
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": set(assigned_rows),
})
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
@bp.post("/")
@require_admin
async def create():
form = await request.form
name = (form.get("name") or "").strip()
if not name:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None
@@ -115,14 +64,14 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
@bp.post("/<int:id>/")
@require_admin
async def save(id: int):
tg = await g.s.get(TagGroup, id)
if not tg:
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
form = await request.form
name = (form.get("name") or "").strip()
@@ -153,7 +102,7 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id))
return redirect(url_for("defpage_tag_group_edit", id=id))
@bp.post("/<int:id>/delete/")
@require_admin
@@ -163,6 +112,6 @@ def register():
await g.s.delete(tg)
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return redirect(url_for("defpage_tag_groups_page"))
return bp

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Blog app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``blog/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("blog", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "blog", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

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

View File

@@ -10,10 +10,18 @@ from quart import (
url_for,
)
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 markupsafe import escape
from shared.sx.helpers import sx_response, render_to_sx
from shared.sx.parser import SxExpr, serialize as sx_serialize
from shared.utils import host_url
def _raw_html_sx(html: str) -> str:
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
if not html:
return ""
return "(raw! " + sx_serialize(html) + ")"
def _post_to_edit_dict(post) -> dict:
"""Convert an ORM Post to a dict matching the shape templates expect.
@@ -52,158 +60,265 @@ def _post_to_edit_dict(post) -> dict:
return d
async def _render_features(features, post, result):
"""Render features panel via .sx defcomp."""
slug = post.get("slug", "")
return await render_to_sx("blog-features-panel-content",
features_url=host_url(url_for("blog.post.admin.update_features", slug=slug)),
calendar_checked=bool(features.get("calendar")),
market_checked=bool(features.get("market")),
show_sumup=bool(features.get("calendar") or features.get("market")),
sumup_url=host_url(url_for("blog.post.admin.update_sumup", slug=slug)),
merchant_code=result.get("sumup_merchant_code") or "",
placeholder="\u2022" * 8 if result.get("sumup_configured") else "sup_sk_...",
sumup_configured=result.get("sumup_configured", False),
checkout_prefix=result.get("sumup_checkout_prefix") or "",
)
def _serialize_markets(markets, slug):
"""Serialize ORM/DTO market objects to dicts for .sx defcomp."""
result = []
for m in markets:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
result.append({
"name": m_name, "slug": m_slug,
"delete_url": host_url(url_for("blog.post.admin.delete_market",
slug=slug, market_slug=m_slug)),
})
return result
def _render_calendar_view(
calendar, year, month, month_name, weekday_names, weeks,
prev_month, prev_month_year, next_month, next_month_year,
prev_year, next_year, month_entries, associated_entry_ids,
post_slug: str,
) -> str:
"""Build calendar month grid HTML."""
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
esc = escape
csrf = generate_csrf_token()
cal_id = calendar.id
def cal_url(y, m):
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
cur_url = cal_url(year, month)
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
nav = (
f'<header class="flex items-center justify-center mb-4">'
f'<nav class="flex items-center gap-2 text-xl">'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&laquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&lsaquo;</a>'
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&rsaquo;</a>'
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">&raquo;</a>'
f'</nav></header>'
)
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
cells: list[str] = []
for week in weeks:
for day in week:
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
day_date = day.date
entry_btns: list[str] = []
for e in month_entries:
e_start = getattr(e, "start_at", None)
if not e_start or e_start.date() != day_date:
continue
e_id = getattr(e, "id", None)
e_name = esc(getattr(e, "name", ""))
t_url = toggle_url_fn(e_id)
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
if e_id in associated_entry_ids:
entry_btns.append(
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
f'<span class="truncate flex-1">{e_name}</span>'
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
f' data-confirm data-confirm-title="Remove entry?"'
f' data-confirm-text="Remove {e_name} from this post?"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><i class="fa fa-times"></i></button></div>'
)
else:
entry_btns.append(
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
f' data-confirm data-confirm-title="Add entry?"'
f' data-confirm-text="Add {e_name} to this post?"'
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' sx-post="{t_url}" sx-trigger="confirmed"'
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
f""" sx-headers='{hx_hdrs}'"""
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
f'><span class="truncate block">{e_name}</span></button>'
)
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
cells.append(
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
)
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
html = (
f'<div id="calendar-view-{cal_id}"'
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
f'{nav}'
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
f'</div>'
)
return _raw_html_sx(html)
async def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
"""Render the associated entries panel."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for as qurl
csrf = generate_csrf_token()
has_entries = False
entry_items: list[str] = []
for calendar in all_calendars:
entries = getattr(calendar, "entries", []) or []
cal_name = getattr(calendar, "name", "")
cal_post = getattr(calendar, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
for entry in entries:
e_id = getattr(entry, "id", None)
if e_id not in associated_entry_ids:
continue
if getattr(entry, "deleted_at", None) is not None:
continue
has_entries = True
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))
img_sx = await render_to_sx("blog-entry-image", src=cal_fi, title=cal_title)
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
entry_items.append(await render_to_sx("blog-associated-entry",
confirm_text=f"This will remove {e_name} from this post",
toggle_url=toggle_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
img=SxExpr(img_sx), name=e_name,
date_str=f"{cal_name} \u2022 {date_str}",
))
if has_entries:
content_sx = await render_to_sx("blog-associated-entries-content",
items=SxExpr("(<> " + " ".join(entry_items) + ")"),
)
else:
content_sx = await render_to_sx("blog-associated-entries-empty")
return await render_to_sx("blog-associated-entries-panel", content=SxExpr(content_sx))
async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
"""Render the OOB nav entries swap."""
entries_list = []
if associated_entries and hasattr(associated_entries, "entries"):
entries_list = associated_entries.entries or []
has_items = bool(entries_list or calendars)
if not has_items:
return await render_to_sx("blog-nav-entries-empty")
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-2"
)
post_slug = post.get("slug", "")
scroll_hs = (
"on load or scroll"
" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
" remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow"
" else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"
)
item_parts = []
for entry in entries_list:
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
cal_slug = getattr(entry, "calendar_slug", "")
if e_start:
entry_path = (
f"/{post_slug}/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
)
date_str = e_start.strftime("%b %d, %Y at %H:%M")
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
entry_path = f"/{post_slug}/{cal_slug}/"
date_str = ""
item_parts.append(await render_to_sx("calendar-entry-nav",
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
))
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
cal_path = f"/{post_slug}/{cal_slug}/"
item_parts.append(await render_to_sx("blog-nav-calendar-item",
href=cal_path, nav_cls=nav_cls, name=cal_name,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return await render_to_sx("scroll-nav-wrapper",
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
arrow_cls="entries-nav-arrow",
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_post_admin" in ep:
from sqlalchemy import select
from shared.models.page_config import PageConfig
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
from shared.sx.page import get_template_context
from sx.sx_components import _post_admin_main_panel_sx
tctx = await get_template_context()
tctx.update({
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
})
g.post_admin_content = _post_admin_main_panel_sx(tctx)
elif "defpage_post_data" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
g.post_data_content = _post_data_content_sx(tctx)
elif "defpage_post_preview" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
preview_ctx = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
from shared.sx.page import get_template_context
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
g.post_preview_content = _preview_main_panel_sx(tctx)
elif "defpage_post_entries" in ep:
from sqlalchemy import select
from shared.models.calendars import Calendar
from ..services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import _post_entries_content_sx
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
g.post_entries_content = _post_entries_content_sx(tctx)
elif "defpage_post_settings" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
g.post_settings_content = _post_settings_content_sx(tctx)
elif "defpage_post_edit" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import _post_edit_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
g.post_edit_content = _post_edit_content_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=[
"post-admin", "post-data", "post-preview",
"post-entries", "post-settings", "post-edit",
])
@bp.put("/features/")
@require_admin
async def update_features(slug: str):
@@ -238,14 +353,7 @@ def register():
})
features = result.get("features", {})
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 "",
)
html = await _render_features(features, post, result)
return sx_response(html)
@bp.put("/admin/sumup/")
@@ -278,13 +386,7 @@ def register():
result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {})
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 "",
)
html = await _render_features(features, post, result)
return sx_response(html)
@bp.get("/entries/calendar/<int:calendar_id>/")
@@ -353,8 +455,7 @@ def register():
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
from sx.sx_components import render_calendar_view
html = render_calendar_view(
html = _render_calendar_view(
calendar_obj, year, month, month_name, weekday_names, weeks,
prev_month, prev_month_year, next_month, next_month_year,
prev_year, next_year, month_entries, associated_entry_ids,
@@ -406,11 +507,9 @@ def register():
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
from sx.sx_components import render_associated_entries, render_nav_entries_oob
post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
admin_list = await _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = await _render_nav_entries_oob(associated_entries, calendars, post)
return sx_response(admin_list + nav_entries_html)
@@ -468,7 +567,7 @@ def register():
except OptimisticLockError:
from urllib.parse import quote
return redirect(
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug))
host_url(url_for("defpage_post_settings", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -479,7 +578,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1")
return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
@bp.post("/edit/")
@require_post_author
@@ -504,11 +603,11 @@ def register():
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
ok, reason = validate_lexical(lexical_doc)
if not ok:
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -544,7 +643,7 @@ def register():
)
except OptimisticLockError:
return redirect(
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug))
host_url(url_for("defpage_post_edit", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -560,7 +659,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# Redirect to GET (PRG pattern) — use post.slug in case it changed
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1"
redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)
@@ -585,8 +684,11 @@ def register():
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
slug = post.get("slug", "")
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = await render_to_sx("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
@bp.post("/markets/new/")
@require_admin
@@ -611,8 +713,11 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
slug = post.get("slug", "")
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = await render_to_sx("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
@bp.delete("/markets/<market_slug>/")
@require_admin
@@ -631,7 +736,10 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
from sx.sx_components import render_markets_panel
return sx_response(render_markets_panel(page_markets, post))
slug = post.get("slug", "")
create_url = host_url(url_for("blog.post.admin.create_market", slug=slug))
html = await render_to_sx("blog-markets-panel-content",
markets=_serialize_markets(page_markets, slug), create_url=create_url)
return sx_response(html)
return bp

View File

@@ -105,27 +105,68 @@ def register():
@cache_page(tag="post.post_detail")
async def post_detail(slug: str):
from shared.sx.page import get_template_context
from sx.sx_components import render_post_page, render_post_oob
from shared.sx.helpers import (
render_to_sx, root_header_sx, full_page_sx, oob_page_sx,
post_header_sx, oob_header_sx, mobile_menu_sx,
post_mobile_nav_sx, mobile_root_nav_sx,
)
from shared.services.registry import services
from shared.browser.app.csrf import generate_csrf_token
from shared.utils import host_url
tctx = await get_template_context()
# Render post content via .sx defcomp
post = tctx.get("post") or {}
user = getattr(g, "user", None)
rights = tctx.get("rights") or {}
blog_url_base = host_url(url_for("blog.index")).rstrip("/index").rstrip("/")
csrf = generate_csrf_token()
svc = services.blog_page
detail_data = svc.post_detail_data(post, user, rights, csrf, blog_url_base)
content = await render_to_sx("blog-post-detail-content", **detail_data)
meta_data = svc.post_meta_data(post, tctx.get("base_title", ""))
meta = await render_to_sx("blog-meta", **meta_data)
if not is_htmx_request():
html = await render_post_page(tctx)
root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
menu = mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx))
html = await full_page_sx(tctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
return await make_response(html)
else:
sx_src = await render_post_oob(tctx)
root_hdr = await root_header_sx(tctx)
post_hdr = await post_header_sx(tctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = await oob_header_sx("root-header-child", "post-header-child", rows)
sx_src = await oob_page_sx(oobs=header_oob, content=content, menu=
mobile_menu_sx(await post_mobile_nav_sx(tctx), await mobile_root_nav_sx(tctx)))
return sx_response(sx_src)
@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 sx.sx_components import render_like_toggle_button
from shared.sx.helpers import render_to_sx
from shared.browser.app.csrf import generate_csrf_token
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
csrf = generate_csrf_token()
async def _like_btn(liked):
if liked:
colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post"
else:
colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post"
return await render_to_sx("market-like-toggle-button",
colour=colour, action=like_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
label=label, icon_cls=icon)
# Get post_id from g.post_data
if not g.user:
return sx_response(render_like_toggle_button(slug, False, like_url), status=403)
return sx_response(await _like_btn(False), status=403)
post_id = g.post_data["post"]["id"]
user_id = g.user.id
@@ -133,9 +174,8 @@ def register():
result = await call_action("likes", "toggle", payload={
"user_id": user_id, "target_type": "post", "target_id": post_id,
})
liked = result["liked"]
return sx_response(render_like_toggle_button(slug, liked, like_url))
return sx_response(await _like_btn(result["liked"]))
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):

View File

@@ -1,53 +1,25 @@
from __future__ import annotations
from quart import Blueprint, make_response, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from quart import Blueprint, request, g, abort
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 shared.sx.helpers import sx_response, render_to_sx
from models import Snippet
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
async def _visible_snippets(session):
"""Return snippets visible to the current user (own + shared + admin-if-admin)."""
uid = g.user.id
is_admin = g.rights.get("admin")
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
if is_admin:
filters.append(Snippet.visibility == "admin")
rows = (await session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
return rows
async def _render_snippets():
"""Render snippets list via service data + .sx defcomp."""
from shared.services.registry import services
data = await services.blog_page.snippets_data(g.s)
return await render_to_sx("blog-snippets-content", **data)
def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
from shared.sx.page import get_template_context
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = snippets
tctx["is_admin"] = is_admin
g.snippets_content = _snippets_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["snippets-page"])
@bp.delete("/<int:snippet_id>/")
@require_login
async def delete_snippet(snippet_id: int):
@@ -63,9 +35,7 @@ def register():
await g.s.delete(snippet)
await g.s.flush()
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, is_admin))
return sx_response(await _render_snippets())
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
@@ -87,8 +57,6 @@ def register():
snippet.visibility = visibility
await g.s.flush()
snippets = await _visible_snippets(g.s)
from sx.sx_components import render_snippets_list
return sx_response(render_snippets_list(snippets, True))
return sx_response(await _render_snippets())
return bp

38
blog/queries.sx Normal file
View File

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

View File

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

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

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

View File

@@ -169,3 +169,117 @@
(details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content)))
;; ---------------------------------------------------------------------------
;; Data-driven content defcomps (called from defpages with service data)
;; ---------------------------------------------------------------------------
;; Snippets — receives serialized snippet dicts from service
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
(~blog-snippets-panel
:list (if (empty? (or snippets (list)))
(~empty-state :icon "fa fa-puzzle-piece"
:message "No snippets yet. Create one from the blog editor.")
(~blog-snippets-list
:rows (map (lambda (s)
(let* ((badge-colours (dict
"private" "bg-stone-200 text-stone-700"
"shared" "bg-blue-100 text-blue-700"
"admin" "bg-amber-100 text-amber-700"))
(vis (or (get s "visibility") "private"))
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
(name (get s "name"))
(owner (get s "owner"))
(can-delete (get s "can_delete")))
(~blog-snippet-row
:name name :owner owner :badge-cls badge-cls :visibility vis
:extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(when can-delete
(~delete-btn
:url (get s "delete_url")
:trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
(or snippets (list)))))))
;; Menu Items — receives serialized menu item dicts from service
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
(~blog-menu-items-panel
:new-url new-url
:list (if (empty? (or menu-items (list)))
(~empty-state :icon "fa fa-inbox"
:message "No menu items yet. Add one to get started!")
(~blog-menu-items-list
:rows (map (lambda (mi)
(~blog-menu-item-row
:img (~img-or-placeholder
:src (get mi "feature_image") :alt (get mi "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
:label (get mi "label")
:slug (get mi "url")
:sort-order (str (or (get mi "sort_order") 0))
:edit-url (get mi "edit_url")
:delete-url (get mi "delete_url")
:confirm-text (str "Remove " (get mi "label") " from the menu?")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
(or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~blog-tag-groups-list
:items (map (lambda (g)
(let* ((fi (get g "feature_image"))
(colour (get g "colour"))
(name (get g "name"))
(initial (slice (or name "?") 0 1))
(icon (if fi
(~blog-tag-group-icon-image :src fi :name name)
(~blog-tag-group-icon-color
:style (if colour (str "background:" colour) "background:#e7e5e4")
:initial initial))))
(~blog-tag-group-li
:icon icon
:edit-href (get g "edit_href")
:name name
:slug (or (get g "slug") "")
:sort-order (or (get g "sort_order") 0))))
(or groups (list)))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
:spans (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
(or unassigned-tags (list)))))))
;; Tag Group Edit — receives serialized tag group + tags from service
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~blog-tag-group-edit-main
:edit-form (~blog-tag-group-edit-form
:save-url save-url :csrf csrf
:name (get group "name")
:colour (get group "colour")
:sort-order (get group "sort_order")
:feature-image (get group "feature_image")
:tags (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "id")
:checked (get t "checked")
:img (when (get t "feature_image")
(~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))

View File

@@ -36,6 +36,37 @@
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
(div :class "pb-8")))
;; ---------------------------------------------------------------------------
;; Data-driven composition — replaces _post_main_panel_sx
;; ---------------------------------------------------------------------------
(defcomp ~blog-post-detail-content (&key slug is-draft publish-requested can-edit edit-href
is-page has-user liked like-url csrf
custom-excerpt tags authors
feature-image html-content sx-content)
(let* ((hx-select "#main-panel")
(draft-sx (when is-draft
(~blog-detail-draft
:publish-requested publish-requested
:edit (when can-edit
(~blog-detail-edit-link :href edit-href :hx-select hx-select)))))
(chrome-sx (when (not is-page)
(~blog-detail-chrome
:like (when has-user
(~blog-detail-like
:like-url like-url
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:heart (if liked "\u2764\ufe0f" "\U0001f90d")))
:excerpt (when (not (= custom-excerpt ""))
(~blog-detail-excerpt :excerpt custom-excerpt))
:at-bar (~blog-at-bar :tags tags :authors authors)))))
(~blog-detail-main
:draft draft-sx
:chrome chrome-sx
:feature-image feature-image
:html-content html-content
:sx-content sx-content)))
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
(<>
(meta :name "robots" :content robots)

View File

@@ -55,6 +55,104 @@
(button :type "submit"
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
;; Edit form — pre-populated version for /<slug>/admin/edit/
(defcomp ~blog-editor-edit-form (&key csrf updated-at title-val excerpt-val
feature-image feature-image-caption
sx-content-val lexical-json
has-sx title-placeholder
status already-emailed
newsletter-options footer-extra)
(let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600")
(active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent")
(inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"))
(form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "updated_at" :value updated-at)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val ""))
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image ""))
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption ""))
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
(div :id "feature-image-empty" :class (if feature-image "hidden" "")
(button :type "button" :id "feature-image-add-btn"
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
"+ Add feature image"))
(div :id "feature-image-filled" :class (str "relative " (if feature-image "" "hidden"))
(img :id "feature-image-preview" :src (or feature-image "") :alt ""
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
(button :type "button" :id "feature-image-delete-btn"
:class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
:title "Remove feature image"
(i :class "fa-solid fa-trash-can"))
(input :type "text" :id "feature-image-caption" :value (or feature-image-caption "")
:placeholder "Add a caption..."
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
(div :id "feature-image-uploading"
:class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"
(i :class "fa-solid fa-spinner fa-spin") " Uploading...")
(input :type "file" :id "feature-image-file"
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
(input :type "text" :name "title" :value (or title-val "") :placeholder title-placeholder
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
(or excerpt-val ""))
;; Editor tabs
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
(button :type "button" :id "editor-tab-sx"
:class (if has-sx active inactive)
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
"SX Editor")
(button :type "button" :id "editor-tab-koenig"
:class (if has-sx inactive active)
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
"Koenig (Legacy)"))
(div :id "sx-editor" :class "relative w-full bg-transparent"
:style (if has-sx "" "display:none"))
(div :id "lexical-editor" :class "relative w-full bg-transparent"
:style (if has-sx "display:none" ""))
;; Initial lexical JSON
(script :id "lexical-initial-data" :type "application/json" lexical-json)
;; Footer: status + publish mode + newsletter + save + badges
(div :class "flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
(select :id "status-select" :name "status" :class sel-cls
(option :value "draft" :selected (= status "draft") "Draft")
(option :value "published" :selected (= status "published") "Published"))
(select :id "publish-mode-select" :name "publish_mode"
:class (str sel-cls (if (= status "published") "" " hidden")
(if already-emailed " opacity-50 pointer-events-none" ""))
:disabled (if already-emailed true nil)
(option :value "web" :selected true "Web only")
(option :value "email" "Email only")
(option :value "both" "Web + Email"))
(select :id "newsletter-select" :name "newsletter_slug"
:class (str sel-cls " hidden")
:disabled (if already-emailed true nil)
newsletter-options)
(button :type "submit"
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
"Save")
(when footer-extra footer-extra)))))
;; Publish-mode show/hide script for edit form
(defcomp ~blog-editor-publish-js (&key already-emailed)
(script
"(function() {"
" var statusSel = document.getElementById('status-select');"
" var modeSel = document.getElementById('publish-mode-select');"
" var nlSel = document.getElementById('newsletter-select');"
(str " var alreadyEmailed = " (if already-emailed "true" "false") ";")
" function sync() {"
" var isPublished = statusSel.value === 'published';"
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
" var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');"
" if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }"
" }"
" statusSel.addEventListener('change', sync);"
" modeSel.addEventListener('change', sync);"
" sync();"
"})();"))
(defcomp ~blog-editor-styles (&key css-href)
(<> (link :rel "stylesheet" :href css-href)
(style

View File

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

View File

@@ -54,6 +54,43 @@
(button :type "submit"
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
;; ---------------------------------------------------------------------------
;; Data-driven composition defcomps — replace Python render_* functions
;; ---------------------------------------------------------------------------
;; Features panel composition — replaces render_features_panel
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked
show-sumup sumup-url merchant-code placeholder
sumup-configured checkout-prefix)
(~blog-features-panel
:form (~blog-features-form
:features-url features-url
:calendar-checked calendar-checked
:market-checked market-checked
:hs-trigger "on change trigger submit on closest <form/>")
:sumup (when show-sumup
(~blog-sumup-form
:sumup-url sumup-url
:merchant-code merchant-code
:placeholder placeholder
:sumup-configured sumup-configured
:checkout-prefix checkout-prefix))))
;; Markets panel composition — replaces render_markets_panel
(defcomp ~blog-markets-panel-content (&key markets create-url)
(~blog-markets-panel
:list (if (empty? (or markets (list)))
(~blog-markets-empty)
(~blog-markets-list
:items (map (lambda (m)
(~blog-market-item
:name (get m "name")
:slug (get m "slug")
:delete-url (get m "delete_url")
:confirm-text (str "Delete market '" (get m "name") "'?")))
(or markets (list)))))
:create-url create-url))
;; Associated entries
(defcomp ~blog-entry-image (&key src title)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,84 +15,95 @@
:layout :blog
:content (editor-page-content))
; --- Post admin pages (nested under /<slug>/admin/) ---
; --- Post admin pages (absolute paths under /<slug>/admin/) ---
(defpage post-admin
:path "/"
:path "/<slug>/admin/"
:auth :admin
:layout (:post-admin :selected "admin")
:content (post-admin-content))
:content (post-admin-content slug))
(defpage post-data
:path "/data/"
:path "/<slug>/admin/data/"
:auth :admin
:layout (:post-admin :selected "data")
:content (post-data-content))
:content (post-data-content slug))
(defpage post-preview
:path "/preview/"
:path "/<slug>/admin/preview/"
:auth :admin
:layout (:post-admin :selected "preview")
:content (post-preview-content))
:content (post-preview-content slug))
(defpage post-entries
:path "/entries/"
:path "/<slug>/admin/entries/"
:auth :admin
:layout (:post-admin :selected "entries")
:content (post-entries-content))
:content (post-entries-content slug))
(defpage post-settings
:path "/settings/"
:path "/<slug>/admin/settings/"
:auth :post_author
:layout (:post-admin :selected "settings")
:content (post-settings-content))
:content (post-settings-content slug))
(defpage post-edit
:path "/edit/"
:path "/<slug>/admin/edit/"
:auth :post_author
:layout (:post-admin :selected "edit")
:content (post-edit-content))
:content (post-edit-content slug))
; --- Settings pages ---
; --- Settings pages (absolute paths) ---
(defpage settings-home
:path "/"
:path "/settings/"
:auth :admin
:layout :blog-settings
:content (settings-content))
:content (div :class "max-w-2xl mx-auto px-4 py-6"))
(defpage cache-page
:path "/cache/"
:path "/settings/cache/"
:auth :admin
:layout :blog-cache
:content (cache-content))
:data (service "blog-page" "cache-data")
:content (~blog-cache-panel :clear-url clear-url :csrf csrf))
; --- Snippets ---
(defpage snippets-page
:path "/"
:path "/settings/snippets/"
:auth :login
:layout :blog-snippets
:content (snippets-content))
:data (service "blog-page" "snippets-data")
:content (~blog-snippets-content
:snippets snippets :is-admin is-admin :csrf csrf))
; --- Menu Items ---
(defpage menu-items-page
:path "/"
:path "/settings/menu_items/"
:auth :admin
:layout :blog-menu-items
:content (menu-items-content))
:data (service "blog-page" "menu-items-data")
:content (~blog-menu-items-content
:menu-items menu-items :new-url new-url :csrf csrf))
; --- Tag Groups ---
(defpage tag-groups-page
:path "/"
:path "/settings/tag-groups/"
:auth :admin
:layout :blog-tag-groups
:content (tag-groups-content))
:data (service "blog-page" "tag-groups-data")
:content (~blog-tag-groups-content
:groups groups :unassigned-tags unassigned-tags
:create-url create-url :csrf csrf))
(defpage tag-group-edit
:path "/<int:id>/"
:path "/settings/tag-groups/<int:id>/"
:auth :admin
:layout :blog-tag-group-edit
:content (tag-group-edit-content))
:data (service "blog-page" "tag-group-edit-data" :id id)
:content (~blog-tag-group-edit-content
:group group :all-tags all-tags
:save-url save-url :delete-url delete-url :csrf csrf))

10
cart/actions.sx Normal file
View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from shared.sx.jinja_bridge import load_service_components # noqa: F401
from decimal import Decimal
from pathlib import Path
@@ -17,7 +17,6 @@ from bp import (
register_page_cart,
register_cart_global,
register_page_admin,
register_fragments,
register_actions,
register_data,
register_inbox,
@@ -141,7 +140,12 @@ def create_app() -> "Quart":
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
import os as _os
load_service_components(_os.path.dirname(_os.path.abspath(__file__)), service_name="cart")
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "cart")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
app.register_blueprint(register_inbox())
@@ -185,8 +189,6 @@ def create_app() -> "Quart":
from sxc.pages import setup_cart_pages
setup_cart_pages()
from shared.sx.pages import mount_pages
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
@@ -196,21 +198,22 @@ def create_app() -> "Quart":
url_prefix="/",
)
# Cart overview at GET /
# Cart overview blueprint (no defpage routes, just action endpoints)
overview_bp = register_cart_overview(url_prefix="/")
mount_pages(overview_bp, "cart", names=["cart-overview"])
app.register_blueprint(overview_bp, url_prefix="/")
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
# Page admin (PUT /payments/ etc.)
admin_bp = register_page_admin()
mount_pages(admin_bp, "cart", names=["cart-admin", "cart-payments"])
app.register_blueprint(admin_bp, url_prefix="/<page_slug>/admin")
# Page cart at /<page_slug>/ (dynamic, matched last)
# Page cart (POST /checkout/ etc.)
page_cart_bp = register_page_cart(url_prefix="/")
mount_pages(page_cart_bp, "cart", names=["page-cart-view"])
app.register_blueprint(page_cart_bp, url_prefix="/<page_slug>")
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "cart")
return app

View File

@@ -2,7 +2,6 @@ from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global
from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data
from .inbox import register_inbox

View File

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

View File

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

@@ -3,24 +3,9 @@
from __future__ import annotations
from quart import Blueprint, g, request
from .services import get_cart_grouped_by_page
from quart import Blueprint
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load overview data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_cart_overview"):
return
from shared.sx.page import get_template_context
from sx.sx_components import _overview_main_panel_sx
page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
g.overview_content = _overview_main_panel_sx(page_groups, ctx)
return bp

View File

@@ -19,26 +19,6 @@ from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.before_request
async def _prepare_page_data():
"""Load page cart data for defpage route."""
endpoint = request.endpoint or ""
if not endpoint.endswith("defpage_page_cart_view"):
return
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
from shared.sx.page import get_template_context
from sx.sx_components import _page_cart_main_panel_sx
ctx = await get_template_context()
g.page_cart_content = _page_cart_main_panel_sx(
ctx, cart, cal_entries, page_tickets, ticket_groups,
total, calendar_total, ticket_total,
)
@bp.post("/checkout/")
async def page_checkout():
post = g.page_post
@@ -48,7 +28,7 @@ def register(url_prefix: str) -> Blueprint:
page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
@@ -56,7 +36,7 @@ def register(url_prefix: str) -> Blueprint:
cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("page_cart.defpage_page_cart_view"))
return redirect(url_for("defpage_page_cart_view"))
ident = current_cart_identity()
@@ -93,7 +73,7 @@ def register(url_prefix: str) -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages 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,79 +1,14 @@
"""Cart app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
All queries are defined in ``cart/queries.sx``.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
from shared.infrastructure.query_blueprint import create_data_blueprint
def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- cart-summary ---
async def _cart_summary():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
page_slug = request.args.get("page_slug")
summary = await services.cart.cart_summary(
g.s, user_id=user_id, session_id=session_id, page_slug=page_slug,
)
return dto_to_dict(summary)
_handlers["cart-summary"] = _cart_summary
# --- cart-items (product slugs + quantities for template rendering) ---
async def _cart_items():
from sqlalchemy import select
from shared.models.market import CartItem
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
filters = [CartItem.deleted_at.is_(None)]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
elif session_id is not None:
filters.append(CartItem.session_id == session_id)
else:
return []
result = await g.s.execute(
select(CartItem).where(*filters)
)
items = result.scalars().all()
return [
{
"product_id": item.product_id,
"product_slug": item.product_slug,
"quantity": item.quantity,
}
for item in items
]
_handlers["cart-items"] = _cart_items
bp, _handlers = create_data_blueprint("cart")
return bp

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,36 +0,0 @@
"""Cart app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``cart/sx/handlers/`` and dispatched via the sx handler registry.
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler_def = get_handler("cart", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "cart", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
return bp

View File

@@ -57,7 +57,7 @@ def register() -> Blueprint:
if not order:
return await make_response("Order not found", 404)
from shared.sx.page import get_template_context
from sx.sx_components import render_order_page, render_order_oob
from sxc.pages import render_order_page, render_order_oob
ctx = await get_template_context()
calendar_entries = ctx.get("calendar_entries")
@@ -122,7 +122,7 @@ def register() -> Blueprint:
if not hosted_url:
from shared.sx.page import get_template_context
from sx.sx_components import render_checkout_error_page
from sxc.pages 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

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

View File

@@ -13,23 +13,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("page_admin", __name__)
@bp.before_request
async def _prepare_page_data():
"""Pre-render admin content for defpage routes."""
endpoint = request.endpoint or ""
if request.method != "GET":
return
if endpoint.endswith("defpage_cart_admin"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_admin_main_panel_sx
ctx = await get_template_context()
g.cart_admin_content = _cart_admin_main_panel_sx(ctx)
elif endpoint.endswith("defpage_cart_payments"):
from shared.sx.page import get_template_context
from sx.sx_components import _cart_payments_main_panel_sx
ctx = await get_template_context()
g.cart_payments_content = _cart_payments_main_panel_sx(ctx)
@bp.put("/payments/")
@require_admin
async def update_sumup(**kwargs):
@@ -64,9 +47,9 @@ def register():
g.page_config = SimpleNamespace(**raw_pc) if raw_pc else None
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_panel
from sxc.pages import render_cart_payments_panel
ctx = await get_template_context()
html = render_cart_payments_panel(ctx)
html = await render_cart_payments_panel(ctx)
return sx_response(html)
return bp

11
cart/queries.sx Normal file
View File

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

View File

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

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

@@ -0,0 +1,187 @@
"""Cart page data service — provides serialized dicts for .sx defpages."""
from __future__ import annotations
from typing import Any
def _serialize_cart_item(item: Any) -> dict:
from quart import url_for
from shared.infrastructure.urls import market_product_url
p = item.product if hasattr(item, "product") else item
slug = p.slug if hasattr(p, "slug") else ""
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
return {
"slug": slug,
"title": p.title if hasattr(p, "title") else "",
"image": p.image if hasattr(p, "image") else None,
"brand": getattr(p, "brand", None),
"is_deleted": getattr(item, "is_deleted", False),
"unit_price": float(unit_price) if unit_price else None,
"special_price": float(p.special_price) if getattr(p, "special_price", None) else None,
"regular_price": float(p.regular_price) if getattr(p, "regular_price", None) else None,
"currency": currency,
"quantity": item.quantity,
"product_id": p.id,
"product_url": market_product_url(slug),
"qty_url": url_for("cart_global.update_quantity", product_id=p.id),
}
def _serialize_cal_entry(e: Any) -> dict:
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else ""
return {
"name": name,
"date_str": f"{start}{end_str}",
"cost": float(cost),
}
def _serialize_ticket_group(tg: Any) -> dict:
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
return {
"entry_name": name,
"ticket_type_name": tt_name or None,
"price": float(price or 0),
"quantity": quantity,
"line_total": float(line_total or 0),
"entry_id": entry_id,
"ticket_type_id": tt_id or None,
"date_str": date_str,
}
def _serialize_page_group(grp: Any) -> dict | None:
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
if not cart_items and not cal_entries and not tickets:
return None
post_data = None
if post:
post_data = {
"slug": post.slug if hasattr(post, "slug") else post.get("slug", ""),
"title": post.title if hasattr(post, "title") else post.get("title", ""),
"feature_image": post.feature_image if hasattr(post, "feature_image") else post.get("feature_image"),
}
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
mp_data = None
if market_place:
mp_data = {"name": market_place.name if hasattr(market_place, "name") else market_place.get("name", "")}
return {
"post": post_data,
"product_count": grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0),
"calendar_count": grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0),
"ticket_count": grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0),
"total": float(grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)),
"market_place": mp_data,
}
class CartPageService:
"""Service for cart page data, callable via (service "cart-page" ...)."""
async def overview_data(self, session, **kw):
from shared.infrastructure.urls import cart_url
from bp.cart.services import get_cart_grouped_by_page
page_groups = await get_cart_grouped_by_page(session)
grp_dicts = [d for d in (_serialize_page_group(grp) for grp in page_groups) if d]
return {
"page_groups": grp_dicts,
"cart_url_base": cart_url(""),
}
async def page_cart_data(self, session, **kw):
from quart import g, request, url_for
from shared.infrastructure.urls import login_url
from shared.utils import route_prefix
from bp.cart.services import total, calendar_total, ticket_total
from bp.cart.services.page_cart import (
get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
post = g.page_post
cart = await get_cart_for_page(session, post.id)
cal_entries = await get_calendar_entries_for_page(session, post.id)
page_tickets = await get_tickets_for_page(session, post.id)
ticket_groups = group_tickets(page_tickets)
# Build summary data
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(page_tickets) if page_tickets else 0
item_count = product_qty + ticket_qty
product_total = total(cart) or 0
cal_total = calendar_total(cal_entries) or 0
tk_total = ticket_total(page_tickets) or 0
grand = float(product_total) + float(cal_total) + float(tk_total)
symbol = "\u00a3"
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
cur = cart[0].product.regular_price_currency
symbol = "\u00a3" if cur == "GBP" else cur
user = getattr(g, "user", None)
page_post = getattr(g, "page_post", None)
summary = {
"item_count": item_count,
"grand_total": grand,
"symbol": symbol,
"is_logged_in": bool(user),
}
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
summary["checkout_action"] = route_prefix() + action
summary["user_email"] = user.email
else:
summary["login_href"] = login_url(request.url)
return {
"cart_items": [_serialize_cart_item(i) for i in cart],
"cal_entries": [_serialize_cal_entry(e) for e in cal_entries],
"ticket_groups": [_serialize_ticket_group(tg) for tg in ticket_groups],
"summary": summary,
}
async def payments_data(self, session, **kw):
from shared.sx.page import get_template_context
ctx = await get_template_context()
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return {"page_config": pc_data}

View File

@@ -52,3 +52,114 @@
(div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
summary))))
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
(defcomp ~cart-item-from-data (&key item)
(let* ((slug (or (get item "slug") ""))
(title (or (get item "title") ""))
(image (get item "image"))
(brand (get item "brand"))
(is-deleted (get item "is_deleted"))
(unit-price (get item "unit_price"))
(special-price (get item "special_price"))
(regular-price (get item "regular_price"))
(currency (or (get item "currency") "GBP"))
(symbol (if (= currency "GBP") "\u00a3" currency))
(quantity (or (get item "quantity") 1))
(product-id (get item "product_id"))
(prod-url (or (get item "product_url") ""))
(qty-url (or (get item "qty_url") ""))
(csrf (csrf-token))
(line-total (when unit-price (* unit-price quantity))))
(~cart-item
:id (str "cart-item-" slug)
:img (if image
(~cart-item-img :src image :alt title)
(~img-or-placeholder :src nil
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
:placeholder-text "No image"))
:prod-url prod-url
:title title
:brand (when brand (~cart-item-brand :brand brand))
:deleted (when is-deleted (~cart-item-deleted))
:price (if unit-price
(<>
(~cart-item-price :text (str symbol (format-decimal unit-price 2)))
(when (and special-price (!= special-price regular-price))
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2)))))
(~cart-item-no-price))
:qty-url qty-url :csrf csrf
:minus (str (- quantity 1))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (when line-total
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
(defcomp ~cart-cal-section-from-data (&key entries)
(when (not (empty? entries))
(~cart-cal-section
:items (map (lambda (e)
(let* ((name (or (get e "name") ""))
(date-str (or (get e "date_str") "")))
(~cart-cal-entry
:name name :date-str date-str
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
entries))))
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
(defcomp ~cart-tickets-section-from-data (&key ticket-groups)
(when (not (empty? ticket-groups))
(let* ((csrf (csrf-token))
(qty-url (url-for "cart_global.update_ticket_quantity")))
(~cart-tickets-section
:items (map (lambda (tg)
(let* ((name (or (get tg "entry_name") ""))
(tt-name (get tg "ticket_type_name"))
(price (or (get tg "price") 0))
(quantity (or (get tg "quantity") 0))
(line-total (or (get tg "line_total") 0))
(entry-id (str (or (get tg "entry_id") "")))
(tt-id (get tg "ticket_type_id"))
(date-str (or (get tg "date_str") "")))
(~cart-ticket-article
:name name
:type-name (when tt-name (~cart-ticket-type-name :name tt-name))
:date-str date-str
:price (str "\u00a3" (format-decimal price 2))
:qty-url qty-url :csrf csrf
:entry-id entry-id
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id)))
:minus (str (max (- quantity 1) 0))
:qty (str quantity)
:plus (str (+ quantity 1))
:line-total (str "Line total: \u00a3" (format-decimal line-total 2)))))
ticket-groups)))))
;; Assembled cart summary — replaces Python _cart_summary_sx
(defcomp ~cart-summary-from-data (&key item-count grand-total symbol is-logged-in checkout-action login-href user-email)
(~cart-summary-panel
:item-count (str item-count)
:subtotal (str symbol (format-decimal grand-total 2))
:checkout (if is-logged-in
(~cart-checkout-form
:action checkout-action :csrf (csrf-token)
:label (str " Checkout as " user-email))
(~cart-checkout-signin :href login-href))))
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
(defcomp ~cart-page-cart-content (&key cart-items cal-entries ticket-groups summary)
(if (and (empty? (or cart-items (list)))
(empty? (or cal-entries (list)))
(empty? (or ticket-groups (list))))
(div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
(~cart-page-panel
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list)))
:cal (when (not (empty? (or cal-entries (list))))
(~cart-cal-section-from-data :entries cal-entries))
:tickets (when (not (empty? (or ticket-groups (list))))
(~cart-tickets-section-from-data :ticket-groups ticket-groups))
:summary summary)))

View File

@@ -39,3 +39,56 @@
(defcomp ~cart-overview-panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" cards)))
(defcomp ~cart-empty ()
(div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
;; Assembled page group card — replaces Python _page_group_card_sx
(defcomp ~cart-page-group-card-from-data (&key grp cart-url-base)
(let* ((post (get grp "post"))
(product-count (or (get grp "product_count") 0))
(calendar-count (or (get grp "calendar_count") 0))
(ticket-count (or (get grp "ticket_count") 0))
(total (or (get grp "total") 0))
(market-place (get grp "market_place"))
(badges (<>
(when (> product-count 0)
(~cart-badge :icon "fa fa-box-open"
:text (str product-count " item" (pluralize product-count))))
(when (> calendar-count 0)
(~cart-badge :icon "fa fa-calendar"
:text (str calendar-count " booking" (pluralize calendar-count))))
(when (> ticket-count 0)
(~cart-badge :icon "fa fa-ticket"
:text (str ticket-count " ticket" (pluralize ticket-count)))))))
(if post
(let* ((slug (or (get post "slug") ""))
(title (or (get post "title") ""))
(feature-image (get post "feature_image"))
(mp-name (if market-place (or (get market-place "name") "") ""))
(display-title (if (!= mp-name "") mp-name title)))
(~cart-group-card
:href (str cart-url-base "/" slug "/")
:img (if feature-image
(~cart-group-card-img :src feature-image :alt title)
(~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
:placeholder-icon "fa fa-store text-xl"))
:display-title display-title
:subtitle (when (!= mp-name "")
(~cart-mp-subtitle :title title))
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))
(~cart-orphan-card
:badges (~cart-badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2))))))
;; Assembled cart overview content — replaces Python _overview_main_panel_sx
(defcomp ~cart-overview-content (&key page-groups cart-url-base)
(if (empty? page-groups)
(~cart-empty)
(~cart-overview-panel
:cards (map (lambda (grp)
(~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base))
page-groups))))

View File

@@ -5,3 +5,27 @@
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :sx-select "#payments-panel")))
;; Assembled cart admin overview content
(defcomp ~cart-admin-content ()
(let* ((payments-href (url-for "defpage_cart_payments")))
(div :id "main-panel"
(div :class "flex items-center justify-between p-3 border-b"
(span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")
(a :href payments-href :class "text-sm underline" "configure")))))
;; Assembled cart payments content
(defcomp ~cart-payments-content (&key page-config)
(let* ((sumup-configured (and page-config (get page-config "sumup_api_key")))
(merchant-code (or (get page-config "sumup_merchant_code") ""))
(checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
(placeholder (if sumup-configured "--------" "sup_sk_..."))
(input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(~cart-payments-panel
:update-url (url-for "page_admin.update_sumup")
:csrf (csrf-token)
:merchant-code merchant-code
:placeholder placeholder
:input-cls input-cls
:sumup-configured sumup-configured
:checkout-prefix checkout-prefix)))

View File

@@ -1,807 +0,0 @@
"""
Cart service s-expression page components.
Renders cart overview, page cart, orders list, and single order detail.
Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations
import os
from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.helpers import (
call_url, root_header_sx, post_admin_header_sx,
post_header_sx as _shared_post_header_sx,
search_desktop_sx, search_mobile_sx,
full_page_sx, oob_page_sx, header_child_sx,
sx_call, SxExpr,
)
from shared.infrastructure.urls import market_product_url, cart_url
# Load cart-specific .sx components + handlers at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)),
service_name="cart")
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_sx)."""
if ctx.get("post") or not page_post:
return ctx
ctx = {**ctx, "post": {
"id": getattr(page_post, "id", None),
"slug": getattr(page_post, "slug", ""),
"title": getattr(page_post, "title", ""),
"feature_image": getattr(page_post, "feature_image", None),
}}
return ctx
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present (for post header row)."""
if ctx.get("container_nav"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
slug = post.get("slug", "")
if not post_id:
return ctx
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build post-level header row from page_post DTO, using shared helper."""
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return _shared_post_header_sx(ctx, oob=oob)
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return sx_call(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
link_label="cart", icon="fa fa-shopping-cart",
child_id="cart-header-child", oob=oob,
)
def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
"""Build the per-page cart header row."""
slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160]
label_parts = []
if page_post and page_post.feature_image:
label_parts.append(sx_call("cart-page-label-img", src=page_post.feature_image))
label_parts.append(f'(span "{escape(title)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return sx_call(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders)."""
return sx_call(
"menu-row-sx",
id="auth-row", level=1, colour="sky",
link_href=call_url(ctx, "account_url", "/"),
link_label="account", icon="fa-solid fa-user",
child_id="auth-header-child", oob=oob,
)
def _orders_header_sx(ctx: dict, list_url: str) -> str:
"""Build the orders section header row."""
return sx_call(
"menu-row-sx",
id="orders-row", level=2, colour="sky",
link_href=list_url, link_label="Orders", icon="fa fa-gbp",
child_id="orders-header-child",
)
# ---------------------------------------------------------------------------
# Cart overview
# ---------------------------------------------------------------------------
def _badge_sx(icon: str, count: int, label: str) -> str:
"""Render a count badge."""
s = "s" if count != 1 else ""
return sx_call("cart-badge", icon=icon, text=f"{count} {label}{s}")
def _page_group_card_sx(grp: Any, ctx: dict) -> str:
"""Render a single page group card for cart overview."""
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0)
calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0)
ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0)
total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
if not cart_items and not cal_entries and not tickets:
return ""
# Count badges
badge_parts = []
if product_count > 0:
badge_parts.append(_badge_sx("fa fa-box-open", product_count, "item"))
if calendar_count > 0:
badge_parts.append(_badge_sx("fa fa-calendar", calendar_count, "booking"))
if ticket_count > 0:
badge_parts.append(_badge_sx("fa fa-ticket", ticket_count, "ticket"))
badges_sx = "(<> " + " ".join(badge_parts) + ")" if badge_parts else '""'
badges_wrap = sx_call("cart-badges-wrap", badges=SxExpr(badges_sx))
if post:
slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
title = post.title if hasattr(post, "title") else post.get("title", "")
feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image")
cart_href = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image:
img = sx_call("cart-group-card-img", src=feature_image, alt=title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="h-16 w-16 rounded-xl",
placeholder_icon="fa fa-store text-xl")
mp_sub = ""
if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = sx_call("cart-mp-subtitle", title=title)
else:
mp_name = ""
display_title = mp_name or title
return sx_call(
"cart-group-card",
href=cart_href, img=SxExpr(img), display_title=display_title,
subtitle=SxExpr(mp_sub) if mp_sub else None,
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
else:
# Orphan items
return sx_call(
"cart-orphan-card",
badges=SxExpr(badges_wrap),
total=f"\u00a3{total:.2f}",
)
def _empty_cart_sx() -> str:
"""Empty cart state."""
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
f' {empty}))'
)
def _overview_main_panel_sx(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
return _empty_cart_sx()
cards = [_page_group_card_sx(grp, ctx) for grp in page_groups]
has_items = any(c for c in cards)
if not has_items:
return _empty_cart_sx()
cards_sx = "(<> " + " ".join(c for c in cards if c) + ")"
return sx_call("cart-overview-panel", cards=SxExpr(cards_sx))
# ---------------------------------------------------------------------------
# Page cart
# ---------------------------------------------------------------------------
def _cart_item_sx(item: Any, ctx: dict) -> str:
"""Render a single product cart item."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
p = item.product if hasattr(item, "product") else item
slug = p.slug if hasattr(p, "slug") else ""
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
symbol = "\u00a3" if currency == "GBP" else currency
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_quantity", product_id=p.id)
prod_url = market_product_url(slug)
if p.image:
img = sx_call("cart-item-img", src=p.image, alt=p.title)
else:
img = sx_call("img-or-placeholder", src=None,
size_cls="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300",
placeholder_text="No image")
price_parts = []
if unit_price:
price_parts.append(sx_call("cart-item-price", text=f"{symbol}{unit_price:.2f}"))
if p.special_price and p.special_price != p.regular_price:
price_parts.append(sx_call("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}"))
else:
price_parts.append(sx_call("cart-item-no-price"))
price_sx = "(<> " + " ".join(price_parts) + ")" if len(price_parts) > 1 else price_parts[0]
deleted_sx = sx_call("cart-item-deleted") if getattr(item, "is_deleted", False) else None
brand_sx = sx_call("cart-item-brand", brand=p.brand) if getattr(p, "brand", None) else None
line_total_sx = None
if unit_price:
lt = unit_price * item.quantity
line_total_sx = sx_call("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}")
return sx_call(
"cart-item",
id=f"cart-item-{slug}", img=SxExpr(img), prod_url=prod_url, title=p.title,
brand=SxExpr(brand_sx) if brand_sx else None,
deleted=SxExpr(deleted_sx) if deleted_sx else None,
price=SxExpr(price_sx),
qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1),
qty=str(item.quantity), plus=str(item.quantity + 1),
line_total=SxExpr(line_total_sx) if line_total_sx else None,
)
def _calendar_entries_sx(entries: list) -> str:
"""Render calendar booking entries in cart."""
if not entries:
return ""
parts = []
for e in entries:
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
start = e.start_at if hasattr(e, "start_at") else ""
end = getattr(e, "end_at", None)
cost = getattr(e, "cost", 0) or 0
end_str = f" \u2013 {end}" if end else ""
parts.append(sx_call(
"cart-cal-entry",
name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-cal-section", items=SxExpr(items_sx))
def _ticket_groups_sx(ticket_groups: list, ctx: dict) -> str:
"""Render ticket groups in cart."""
if not ticket_groups:
return ""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
qty_url = url_for("cart_global.update_ticket_quantity")
parts = []
for tg in ticket_groups:
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
if end_at:
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
tt_name_sx = sx_call("cart-ticket-type-name", name=tt_name) if tt_name else None
tt_hidden_sx = sx_call("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else None
parts.append(sx_call(
"cart-ticket-article",
name=name,
type_name=SxExpr(tt_name_sx) if tt_name_sx else None,
date_str=date_str,
price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf,
entry_id=str(entry_id),
type_hidden=SxExpr(tt_hidden_sx) if tt_hidden_sx else None,
minus=str(max(quantity - 1, 0)), qty=str(quantity),
plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("cart-tickets-section", items=SxExpr(items_sx))
def _cart_summary_sx(ctx: dict, cart: list, cal_entries: list, tickets: list,
total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""Render the order summary sidebar."""
from shared.browser.app.csrf import generate_csrf_token
from quart import g, url_for, request
from shared.infrastructure.urls import login_url
csrf = generate_csrf_token()
product_qty = sum(ci.quantity for ci in cart) if cart else 0
ticket_qty = len(tickets) if tickets else 0
item_count = product_qty + ticket_qty
product_total = total_fn(cart) or 0
cal_total = cal_total_fn(cal_entries) or 0
tk_total = ticket_total_fn(tickets) or 0
grand = float(product_total) + float(cal_total) + float(tk_total)
symbol = "\u00a3"
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
cur = cart[0].product.regular_price_currency
symbol = "\u00a3" if cur == "GBP" else cur
user = getattr(g, "user", None)
page_post = ctx.get("page_post")
if user:
if page_post:
action = url_for("page_cart.page_checkout")
else:
action = url_for("cart_global.checkout")
from shared.utils import route_prefix
action = route_prefix() + action
checkout_sx = sx_call(
"cart-checkout-form",
action=action, csrf=csrf, label=f" Checkout as {user.email}",
)
else:
href = login_url(request.url)
checkout_sx = sx_call("cart-checkout-signin", href=href)
return sx_call(
"cart-summary-panel",
item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}",
checkout=SxExpr(checkout_sx),
)
def _page_cart_main_panel_sx(ctx: dict, cart: list, cal_entries: list,
tickets: list, ticket_groups: list,
total_fn: Any, cal_total_fn: Any,
ticket_total_fn: Any) -> str:
"""Page cart main panel."""
if not cart and not cal_entries and not tickets:
empty = sx_call("empty-state", icon="fa fa-shopping-cart",
message="Your cart is empty", cls="text-center")
return (
'(div :class "max-w-full px-3 py-3 space-y-3"'
' (div :id "cart"'
' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"'
f' {empty})))'
)
item_parts = [_cart_item_sx(item, ctx) for item in cart]
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else '""'
cal_sx = _calendar_entries_sx(cal_entries)
tickets_sx = _ticket_groups_sx(ticket_groups, ctx)
summary_sx = _cart_summary_sx(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
return sx_call(
"cart-page-panel",
items=SxExpr(items_sx),
cal=SxExpr(cal_sx) if cal_sx else None,
tickets=SxExpr(tickets_sx) if tickets_sx else None,
summary=SxExpr(summary_sx),
)
# ---------------------------------------------------------------------------
# Orders list (same pattern as orders service)
# ---------------------------------------------------------------------------
def _order_row_sx(order: Any, detail_url: str) -> str:
"""Render a single order as desktop table row + mobile card."""
status = order.status or "pending"
sl = status.lower()
pill = (
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
else "border-stone-300 bg-stone-50 text-stone-700"
)
pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}"
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
desktop = sx_call(
"order-row-desktop",
oid=f"#{order.id}", created=created, desc=order.description or "",
total=total, pill=pill_cls, status=status, url=detail_url,
)
mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}"
mobile = sx_call(
"order-row-mobile",
oid=f"#{order.id}", pill=mobile_pill, status=status,
created=created, total=total, url=detail_url,
)
return "(<> " + desktop + " " + mobile + ")"
def _orders_rows_sx(orders: list, page: int, total_pages: int,
url_for_fn: Any, qs_fn: Any) -> str:
"""Render order rows + infinite scroll sentinel."""
from shared.utils import route_prefix
pfx = route_prefix()
parts = [
_order_row_sx(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
for o in orders
]
if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
parts.append(sx_call(
"infinite-scroll",
url=next_url, page=page, total_pages=total_pages,
id_prefix="orders", colspan=5,
))
else:
parts.append(sx_call("order-end-row"))
return "(<> " + " ".join(parts) + ")"
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
"""Main panel for orders list."""
if not orders:
return sx_call("order-empty-state")
return sx_call("order-table", rows=SxExpr(rows_sx))
def _orders_summary_sx(ctx: dict) -> str:
"""Filter section for orders list."""
return sx_call("order-list-header", search_mobile=SxExpr(search_mobile_sx(ctx)))
# ---------------------------------------------------------------------------
# Single order detail
# ---------------------------------------------------------------------------
def _order_items_sx(order: Any) -> str:
"""Render order items list."""
if not order or not order.items:
return ""
parts = []
for item in order.items:
prod_url = market_product_url(item.product_slug)
if item.product_image:
img = sx_call(
"order-item-image",
src=item.product_image, alt=item.product_title or "Product image",
)
else:
img = sx_call("order-item-no-image")
parts.append(sx_call(
"order-item-row",
href=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
pid=f"Product ID: {item.product_id}",
qty=f"Qty: {item.quantity}",
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-items-panel", items=SxExpr(items_sx))
def _order_summary_sx(order: Any) -> str:
"""Order summary card."""
return sx_call(
"order-summary-card",
order_id=order.id,
created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
description=order.description, status=order.status, currency=order.currency,
total_amount=f"{order.total_amount:.2f}" if order.total_amount else None,
)
def _order_calendar_items_sx(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order."""
if not calendar_entries:
return ""
parts = []
for e in calendar_entries:
st = e.state or ""
pill = (
"bg-emerald-100 text-emerald-800" if st == "confirmed"
else "bg-amber-100 text-amber-800" if st == "provisional"
else "bg-blue-100 text-blue-800" if st == "ordered"
else "bg-stone-100 text-stone-700"
)
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
parts.append(sx_call(
"order-calendar-entry",
name=e.name, pill=pill_cls, status=st.capitalize(),
date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}",
))
items_sx = "(<> " + " ".join(parts) + ")"
return sx_call("order-calendar-section", items=SxExpr(items_sx))
def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_sx(order)
items = _order_items_sx(order)
cal = _order_calendar_items_sx(calendar_entries)
return sx_call(
"order-detail-panel",
summary=SxExpr(summary),
items=SxExpr(items) if items else None,
calendar=SxExpr(cal) if cal else None,
)
def _order_filter_sx(order: Any, list_url: str, recheck_url: str,
pay_url: str, csrf_token: str) -> str:
"""Filter section for single order detail."""
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
status = order.status or "pending"
pay_sx = None
if status != "paid":
pay_sx = sx_call("order-pay-btn", url=pay_url)
return sx_call(
"order-detail-filter",
info=f"Placed {created} \u00b7 Status: {status}",
list_url=list_url, recheck_url=recheck_url, csrf=csrf_token,
pay=SxExpr(pay_sx) if pay_sx else None,
)
# ---------------------------------------------------------------------------
# Public API: Cart overview
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Public API: Orders list
# ---------------------------------------------------------------------------
async def render_orders_page(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Full page: orders list."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
hdr = root_header_sx(ctx)
auth = _auth_header_sx(ctx)
orders_hdr = _orders_header_sx(ctx, list_url)
auth_child = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + auth + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr)) + ")"),
)
header_rows = "(<> " + hdr + " " + auth_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
async def render_orders_rows(ctx: dict, orders: list, page: int,
total_pages: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Pagination: just the table rows."""
return _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
async def render_orders_oob(ctx: dict, orders: list, page: int,
total_pages: int, search: str | None,
search_count: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""OOB response for orders list."""
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
list_url = route_prefix() + url_for_fn("orders.list_orders")
rows = _orders_rows_sx(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_sx(orders, rows)
auth_oob = _auth_header_sx(ctx, oob=True)
auth_child_oob = sx_call(
"oob-header-sx",
parent_id="auth-header-child",
row=SxExpr(_orders_header_sx(ctx, list_url)),
)
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs,
filter=_orders_summary_sx(ctx),
aside=search_desktop_sx(ctx),
content=main)
# ---------------------------------------------------------------------------
# Public API: Single order detail
# ---------------------------------------------------------------------------
async def render_order_page(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""Full page: single order detail."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_sx(ctx)
order_row = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
)
order_child = sx_call(
"header-child-sx",
inner=SxExpr("(<> " + _auth_header_sx(ctx) + " " + sx_call("header-child-sx", id="auth-header-child", inner=SxExpr(
"(<> " + _orders_header_sx(ctx, list_url) + " " + sx_call("header-child-sx", id="orders-header-child", inner=SxExpr(order_row)) + ")"
)) + ")"),
)
header_rows = "(<> " + hdr + " " + order_child + ")"
return full_page_sx(ctx, header_rows=header_rows, filter=filt, content=main)
async def render_order_oob(ctx: dict, order: Any,
calendar_entries: list | None,
url_for_fn: Any) -> str:
"""OOB response for single order detail."""
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
main = _order_main_sx(order, calendar_entries)
filt = _order_filter_sx(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sx_call(
"menu-row-sx",
id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp",
oob=True,
)
orders_child_oob = sx_call("oob-header-sx",
parent_id="orders-header-child",
row=SxExpr(order_row_oob))
root_oob = root_header_sx(ctx, oob=True)
oobs = "(<> " + orders_child_oob + " " + root_oob + ")"
return oob_page_sx(oobs=oobs, filter=filt, content=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error
# ---------------------------------------------------------------------------
def _checkout_error_filter_sx() -> str:
return sx_call("checkout-error-header")
def _checkout_error_content_sx(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = None
if order:
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}")
back_url = cart_url("/")
return sx_call(
"checkout-error-content",
msg=err_msg,
order=SxExpr(order_sx) if order_sx else None,
back_url=back_url,
)
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error."""
hdr = root_header_sx(ctx)
filt = _checkout_error_filter_sx()
content = _checkout_error_content_sx(error, order)
return full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
# ---------------------------------------------------------------------------
# Page admin (/<page_slug>/admin/)
# ---------------------------------------------------------------------------
def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
"""Build the page-level admin header row -- delegates to shared helper."""
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
def _cart_admin_main_panel_sx(ctx: dict) -> str:
"""Admin overview panel -- links to sub-admin pages."""
from quart import url_for
payments_href = url_for("page_admin.defpage_cart_payments")
return (
'(div :id "main-panel"'
' (div :class "flex items-center justify-between p-3 border-b"'
' (span :class "font-medium" (i :class "fa fa-credit-card text-purple-600 mr-1") " Payments")'
f' (a :href "{payments_href}" :class "text-sm underline" "configure")))'
)
def _cart_payments_main_panel_sx(ctx: dict) -> str:
"""Render SumUp payment config form."""
from quart import url_for
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
page_config = ctx.get("page_config")
sumup_configured = bool(page_config and getattr(page_config, "sumup_api_key", None))
merchant_code = (getattr(page_config, "sumup_merchant_code", None) or "") if page_config else ""
checkout_prefix = (getattr(page_config, "sumup_checkout_prefix", None) or "") if page_config else ""
update_url = url_for("page_admin.update_sumup")
placeholder = "--------" if sumup_configured else "sup_sk_..."
input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
return sx_call("cart-payments-panel",
update_url=update_url, csrf=csrf,
merchant_code=merchant_code, placeholder=placeholder,
input_cls=input_cls, sumup_configured=sumup_configured,
checkout_prefix=checkout_prefix)
def render_cart_payments_panel(ctx: dict) -> str:
"""Render the payments config panel for PUT response."""
return _cart_payments_main_panel_sx(ctx)

View File

@@ -1,13 +1,15 @@
"""Cart defpage setup — registers layouts, page helpers, and loads .sx pages."""
"""Cart defpage setup — registers layouts and loads .sx pages."""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from shared.sx.parser import SxExpr
def setup_cart_pages() -> None:
"""Register cart-specific layouts, page helpers, and load page definitions."""
"""Register cart-specific layouts and load page definitions."""
_register_cart_layouts()
_register_cart_helpers()
_load_cart_page_files()
@@ -17,6 +19,280 @@ def _load_cart_page_files() -> None:
load_page_dir(os.path.dirname(__file__), "cart")
# ---------------------------------------------------------------------------
# Header helpers (moved from sx_components.py)
# ---------------------------------------------------------------------------
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
"""Ensure ctx has a 'post' dict from page_post DTO."""
if ctx.get("post") or not page_post:
return ctx
return {**ctx, "post": {
"id": getattr(page_post, "id", None),
"slug": getattr(page_post, "slug", ""),
"title": getattr(page_post, "title", ""),
"feature_image": getattr(page_post, "feature_image", None),
}}
async def _ensure_container_nav(ctx: dict) -> dict:
"""Fetch container_nav if not already present."""
if ctx.get("container_nav"):
return ctx
post = ctx.get("post") or {}
post_id = post.get("id")
if not post_id:
return ctx
slug = post.get("slug", "")
from shared.infrastructure.fragments import fetch_fragments
nav_params = {
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
}
events_nav, market_nav = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
], required=False)
return {**ctx, "container_nav": events_nav + market_nav}
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
from shared.sx.helpers import post_header_sx as _shared_post_header_sx
ctx = _ensure_post_ctx(ctx, page_post)
ctx = await _ensure_container_nav(ctx)
return await _shared_post_header_sx(ctx, oob=oob)
async def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
from shared.sx.helpers import render_to_sx, call_url
return await render_to_sx(
"menu-row-sx",
id="cart-row", level=1, colour="sky",
link_href=call_url(ctx, "cart_url", "/"),
link_label="cart", icon="fa fa-shopping-cart",
child_id="cart-header-child", oob=oob,
)
async def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
from shared.sx.helpers import render_to_sx, call_url
slug = page_post.slug if page_post else ""
title = ((page_post.title if page_post else None) or "")[:160]
label_parts = []
if page_post and page_post.feature_image:
label_parts.append(await render_to_sx("cart-page-label-img", src=page_post.feature_image))
label_parts.append(f'(span "{escape(title)}")')
label_sx = "(<> " + " ".join(label_parts) + ")"
nav_sx = await render_to_sx("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
return await render_to_sx(
"menu-row-sx",
id="page-cart-row", level=2, colour="sky",
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx), oob=oob,
)
async def _auth_header_sx(ctx: dict, *, oob: bool = False) -> str:
from shared.sx.helpers import render_to_sx, call_url
return await render_to_sx(
"auth-header-row-simple",
account_url=call_url(ctx, "account_url", ""),
oob=oob,
)
async def _orders_header_sx(ctx: dict, list_url: str) -> str:
from shared.sx.helpers import render_to_sx
return await render_to_sx("orders-header-row", list_url=list_url)
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
from shared.sx.helpers import post_admin_header_sx
slug = page_post.slug if page_post else ""
ctx = _ensure_post_ctx(ctx, page_post)
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------
# Order serialization helpers (used by route render functions below)
# ---------------------------------------------------------------------------
def _serialize_order(order: Any) -> dict:
from shared.infrastructure.urls import market_product_url
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
items = []
if order.items:
for item in order.items:
items.append({
"product_image": item.product_image,
"product_title": item.product_title or "Unknown product",
"product_id": item.product_id,
"product_slug": item.product_slug,
"product_url": market_product_url(item.product_slug),
"quantity": item.quantity,
"unit_price_formatted": f"{item.unit_price or 0:.2f}",
"currency": item.currency or order.currency or "GBP",
})
return {
"id": order.id,
"status": order.status or "pending",
"created_at_formatted": created,
"description": order.description or "",
"total_formatted": f"{order.total_amount or 0:.2f}",
"total_amount": float(order.total_amount or 0),
"currency": order.currency or "GBP",
"items": items,
}
def _serialize_calendar_entry(e: Any) -> dict:
st = e.state or ""
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
return {"name": e.name, "state": st, "date_str": ds, "cost_formatted": f"{e.cost or 0:.2f}"}
# ---------------------------------------------------------------------------
# Render functions (called by routes)
# ---------------------------------------------------------------------------
async def render_orders_page(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, full_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
hdr = await root_header_sx(ctx)
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_inner = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(orders_hdr))
auth_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child_inner + ")"))
header_rows = "(<> " + hdr + " " + auth_child + ")"
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
aside=await search_desktop_sx(ctx), content=content)
async def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx
from shared.utils import route_prefix
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
parts = []
for od in order_dicts:
parts.append(await render_to_sx("order-row-pair", order=od, detail_url_prefix=detail_url_prefix))
if page < total_pages:
next_url = list_url + qs_fn(page=page + 1)
parts.append(await render_to_sx("infinite-scroll", url=next_url, page=page,
total_pages=total_pages, id_prefix="orders", colspan=5))
else:
parts.append(await render_to_sx("order-end-row"))
return "(<> " + " ".join(parts) + ")"
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, search_desktop_sx, search_mobile_sx, oob_page_sx
from shared.utils import route_prefix
ctx["search"] = search
ctx["search_count"] = search_count
pfx = route_prefix()
list_url = pfx + url_for_fn("orders.list_orders")
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
order_dicts = [_serialize_order(o) for o in orders]
content = await render_to_sx("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
auth_oob = await _auth_header_sx(ctx, oob=True)
orders_hdr = await _orders_header_sx(ctx, list_url)
auth_child_oob = await render_to_sx("oob-header-sx", parent_id="auth-header-child", row=SxExpr(orders_hdr))
root_oob = await root_header_sx(ctx, oob=True)
oobs = "(<> " + auth_oob + " " + auth_child_oob + " " + root_oob + ")"
filt = await render_to_sx("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
async def render_order_page(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
hdr = await root_header_sx(ctx)
order_row = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp")
auth = await _auth_header_sx(ctx)
orders_hdr = await _orders_header_sx(ctx, list_url)
orders_child = await render_to_sx("header-child-sx", id="orders-header-child", inner=SxExpr(order_row))
auth_inner = "(<> " + orders_hdr + " " + orders_child + ")"
auth_child = await render_to_sx("header-child-sx", id="auth-header-child", inner=SxExpr(auth_inner))
order_child = await render_to_sx("header-child-sx", inner=SxExpr("(<> " + auth + " " + auth_child + ")"))
return await full_page_sx(ctx, header_rows="(<> " + hdr + " " + order_child + ")", filter=filt, content=main)
async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
from shared.sx.helpers import render_to_sx, root_header_sx, oob_page_sx
from shared.utils import route_prefix
from shared.browser.app.csrf import generate_csrf_token
pfx = route_prefix()
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
list_url = pfx + url_for_fn("orders.list_orders")
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
order_data = _serialize_order(order)
cal_data = [_serialize_calendar_entry(e) for e in (calendar_entries or [])]
main = await render_to_sx("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = await render_to_sx("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
order_row_oob = await render_to_sx("menu-row-sx", id="order-row", level=3, colour="sky",
link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", oob=True)
orders_child_oob = await render_to_sx("oob-header-sx", parent_id="orders-header-child", row=SxExpr(order_row_oob))
root_oob = await root_header_sx(ctx, oob=True)
return await oob_page_sx(oobs="(<> " + orders_child_oob + " " + root_oob + ")", filter=filt, content=main)
async def render_checkout_error_page(ctx, error=None, order=None):
from shared.sx.helpers import render_to_sx, root_header_sx, full_page_sx
from shared.infrastructure.urls import cart_url
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_sx = await render_to_sx("checkout-error-order-id", oid=f"#{order.id}") if order else None
hdr = await root_header_sx(ctx)
filt = await render_to_sx("checkout-error-header")
content = await render_to_sx("checkout-error-content", msg=err_msg,
order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/"))
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
async def render_cart_payments_panel(ctx):
from shared.sx.helpers import render_to_sx
page_config = ctx.get("page_config")
pc_data = None
if page_config:
pc_data = {
"sumup_api_key": bool(getattr(page_config, "sumup_api_key", None)),
"sumup_merchant_code": getattr(page_config, "sumup_merchant_code", None) or "",
"sumup_checkout_prefix": getattr(page_config, "sumup_checkout_prefix", None) or "",
}
return await render_to_sx("cart-payments-content", page_config=pc_data)
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
@@ -27,95 +303,51 @@ def _register_cart_layouts() -> None:
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
page_post = ctx.get("page_post")
root_hdr = root_header_sx(ctx)
child = _cart_header_sx(ctx)
page_hdr = _page_cart_header_sx(ctx, page_post)
nested = sx_call(
root_hdr = await root_header_sx(ctx)
child = await _cart_header_sx(ctx)
page_hdr = await _page_cart_header_sx(ctx, page_post)
inner_child = await render_to_sx("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr))
nested = await render_to_sx(
"header-child-sx",
inner=SxExpr("(<> " + child + " " + sx_call("header-child-sx", id="cart-header-child", inner=SxExpr(page_hdr)) + ")"),
inner=SxExpr("(<> " + child + " " + inner_child + ")"),
)
return "(<> " + root_hdr + " " + nested + ")"
def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _cart_header_sx, _page_cart_header_sx
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, render_to_sx
from shared.sx.parser import SxExpr
page_post = ctx.get("page_post")
child_oob = sx_call("oob-header-sx",
page_hdr = await _page_cart_header_sx(ctx, page_post)
child_oob = await render_to_sx("oob-header-sx",
parent_id="cart-header-child",
row=SxExpr(_page_cart_header_sx(ctx, page_post)))
cart_hdr_oob = _cart_header_sx(ctx, oob=True)
root_hdr_oob = root_header_sx(ctx, oob=True)
row=SxExpr(page_hdr))
cart_hdr_oob = await _cart_header_sx(ctx, oob=True)
root_hdr_oob = await root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + cart_hdr_oob + " " + root_hdr_oob + ")"
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _post_header_sx, _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
root_hdr = root_header_sx(ctx)
root_hdr = await root_header_sx(ctx)
post_hdr = await _post_header_sx(ctx, page_post)
admin_hdr = _cart_page_admin_header_sx(ctx, page_post, selected=selected)
admin_hdr = await _cart_page_admin_header_sx(ctx, page_post, selected=selected)
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _cart_page_admin_header_sx
page_post = ctx.get("page_post")
selected = kw.get("selected", "")
return _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_cart_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("cart", {
"overview-content": _h_overview_content,
"page-cart-content": _h_page_cart_content,
"cart-admin-content": _h_cart_admin_content,
"cart-payments-content": _h_cart_payments_content,
})
def _h_overview_content():
from quart import g
page_groups = getattr(g, "overview_page_groups", [])
from sx.sx_components import _overview_main_panel_sx
# _overview_main_panel_sx needs ctx for url helpers — use g-based approach
# The function reads cart_url from ctx, which we can get from template context
from shared.sx.page import get_template_context
import asyncio
# Page helpers are sync — we pre-compute in before_request
return getattr(g, "overview_content", "")
def _h_page_cart_content():
from quart import g
return getattr(g, "page_cart_content", "")
def _h_cart_admin_content():
from sx.sx_components import _cart_admin_main_panel_sx
from shared.sx.page import get_template_context
# Sync helper — _cart_admin_main_panel_sx is sync, but needs ctx
# We can pre-compute in before_request, or use get_template_context_sync-like pattern
from quart import g
return getattr(g, "cart_admin_content", "")
def _h_cart_payments_content():
from quart import g
return getattr(g, "cart_payments_content", "")

View File

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

View File

@@ -46,6 +46,7 @@ services:
- ./blog/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py
- ./blog/sx:/app/sx
- ./blog/sxc:/app/sxc
- ./blog/bp:/app/bp
- ./blog/services:/app/services
- ./blog/templates:/app/templates
@@ -84,6 +85,7 @@ services:
- ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py
- ./market/sx:/app/sx
- ./market/sxc:/app/sxc
- ./market/bp:/app/bp
- ./market/services:/app/services
- ./market/templates:/app/templates
@@ -121,6 +123,7 @@ services:
- ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py
- ./cart/sx:/app/sx
- ./cart/sxc:/app/sxc
- ./cart/bp:/app/bp
- ./cart/services:/app/services
- ./cart/templates:/app/templates
@@ -158,6 +161,7 @@ services:
- ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py
- ./events/sx:/app/sx
- ./events/sxc:/app/sxc
- ./events/bp:/app/bp
- ./events/services:/app/services
- ./events/templates:/app/templates
@@ -195,6 +199,7 @@ services:
- ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py
- ./federation/sx:/app/sx
- ./federation/sxc:/app/sxc
- ./federation/bp:/app/bp
- ./federation/services:/app/services
- ./federation/templates:/app/templates
@@ -232,6 +237,7 @@ services:
- ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py
- ./account/sx:/app/sx
- ./account/sxc:/app/sxc
- ./account/bp:/app/bp
- ./account/services:/app/services
- ./account/templates:/app/templates
@@ -331,6 +337,7 @@ services:
- ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py
- ./orders/sx:/app/sx
- ./orders/sxc:/app/sxc
- ./orders/bp:/app/bp
- ./orders/services:/app/services
- ./orders/templates:/app/templates

View File

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

View File

@@ -358,3 +358,89 @@ Each service migrates independently, no coordination needed:
3. Enable SSR for bots (Phase 2) — per-page opt-in
4. Client data primitives (Phase 4) — global once sx.js updated
5. Data-only navigation (Phase 5) — automatic for any `defpage` route
---
## Why: Architectural Rationale
The end state is: **sx.js is the only JavaScript in the browser.** All application code — components, pages, routing, event handling, data fetching — is expressed in sx, evaluated by the interpreter, with behavior mediated through bound primitives.
### Benefits
**Single language everywhere.** Components, pages, routing, event handling, data fetching — all sx. No context-switching between JS idioms and template syntax. One language for the entire frontend and the server rendering path.
**Portability.** The same source runs on any VM that implements the ~50-primitive interface. Today: Python + JS. Tomorrow: WASM, edge workers, native mobile, embedded devices. Coupled to a primitive contract, not to a specific runtime.
**Smaller wire transfer.** S-expressions are terser than equivalent JS. Combined with content-addressed caching (hash/localStorage), most navigations transfer zero code — just data.
**Inspectability.** The sx source is the running program — no build step, no source maps, no minification. View source shows exactly what executes. The AST is the structure the evaluator walks. Debugging is tracing a tree.
**Controlled surface area.** The only JS that runs is sx.js. Everything else is mediated through defined primitives. No npm supply chain. No third-party scripts with ambient DOM access. Components can only do what primitives allow — the capability surface is fully controlled.
**Hot-reloadable everything.** Components are data (cached AST). Swapping a definition is replacing a dict entry. No module system, no import graph, no HMR machinery. Already works for .sx file changes in dev mode — extends to behaviors too.
**AI-friendly.** S-expressions are trivially parseable and generatable. An LLM produces correct sx far more reliably than JS/JSX — fewer syntax edge cases, no semicolons/braces/arrow-function ambiguities. The codebase becomes more amenable to automated generation and transformation.
**Security boundary.** No `eval()`, no dynamic `<script>` injection, no prototype pollution. The sx evaluator is a sandbox — it only resolves symbols against the primitive table and component env. Auditing what any sx expression can do means auditing the primitive bindings.
### Performance and WASM
The tradeoff is interpreter overhead — a tree-walking interpreter is slower than native JS execution. For UI rendering (building DOM, handling events, fetching data), this is not the bottleneck — DOM operations dominate, and those are the same speed regardless of initiator.
If performance ever becomes a concern, WASM is the escape hatch at three levels:
1. **Evaluator in WASM.** Rewrite `sxEval` in Rust/Zig → WASM. The tight inner loop (symbol lookup, env traversal, function application) runs ~10-50x faster. DOM rendering stays in JS (it calls browser APIs regardless).
2. **Compile sx to WASM.** Ahead-of-time compiler: `.sx` → WASM modules. Each `defcomp` becomes a WASM function returning DOM instructions. Eliminates the interpreter entirely. The content-addressed cache stores compiled WASM blobs instead of sx source.
3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change.
The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change.
### Server-Driven by Default: The React Question
The sx system is architecturally aligned with HTMX/LiveView — server-driven UI — even though it does far more on the client (full s-expression evaluation, DOM rendering, morph reconciliation, component caching). The server is the single source of truth. Every UI state is a URL. Auth is enforced at render time. There are no state synchronization bugs because there is no client state to synchronize.
React's client-state model (`useState`, `useEffect`, Context, Suspense) exists because React was built for SPAs that need to feel like native apps — optimistic updates, offline capability, instant feedback without network latency. But it created an entire category of problems: state management libraries, hydration mismatches, cache invalidation, stale closures, memory leaks from forgotten cleanup, the `useEffect` footgun.
**The question is not "should sx have useState" — it's which specific interactions actually suffer from the server round-trip.**
For most of our apps, that's a very short list:
- Toggle a mobile nav panel
- Gallery image switching
- Quantity steppers
- Live search-as-you-type
These don't need a general-purpose reactive state system. They need **targeted client-side primitives** that handle those specific cases without abandoning the server-driven model.
**The dangerous path:** Add `useState` → need `useEffect` for cleanup → need Context to avoid prop drilling → need Suspense for async state → rebuild React inside sx → lose the simplicity that makes the server-driven model work.
**The careful path:** Keep server-driven as the default. Add explicit, targeted escape hatches for interactions that genuinely need client-side state. Make those escape hatches obviously different from the normal flow so they don't creep into everything.
#### What sx has vs React
| React feature | SX status | Verdict |
|---|---|---|
| Components + props | `defcomp` + `&key` | Done — cleaner than JSX |
| Fragments, conditionals, lists | `<>`, `if`/`when`/`cond`, `map` | Done — more expressive |
| Macros | `defmacro` | Done — React has nothing like this |
| OOB updates / portals | `sx-swap-oob` | Done — more powerful (server-driven) |
| DOM reconciliation | `_morphDOM` (id-keyed) | Done — works during SxEngine swaps |
| Reactive client state | None | **By design.** Server is source of truth. |
| Component lifecycle | None | Add targeted primitives if body.js behaviors move to sx |
| Context / providers | `_componentEnv` global | Sufficient for auth/theme; revisit if trees get deep |
| Suspense / loading | `sx-request` CSS class | Sufficient for server-driven; revisit for Phase 4 client data |
| Two-way data binding | None | Not needed — HTMX model (form POST → new HTML) works |
| Error boundaries | Global `sx:responseError` | Sufficient; per-component boundaries are a future nice-to-have |
| Keyed list reconciliation | id-based morph | Works; add `:key` prop support if list update bugs arise |
#### Targeted escape hatches (not a general state system)
For the few interactions that need client-side responsiveness, add **specific primitives** rather than a general framework:
- `(toggle! el "class")` — CSS class toggle, no server trip
- `(set-attr! el "attr" value)` — attribute manipulation
- `(on-event el "click" handler)` — declarative event binding within sx
- `(timer interval-ms handler)` — with automatic cleanup on DOM removal
These are imperative DOM operations exposed as primitives — not reactive state. They let components handle simple client-side interactions without importing React's entire mental model. The server-driven flow remains the default for anything involving data.

65
events/actions.sx Normal file
View File

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

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, abort, request
@@ -9,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_actions, register_data
async def events_context() -> dict:
@@ -112,7 +111,9 @@ def create_app() -> "Quart":
url_prefix="/<slug>/markets",
)
app.register_blueprint(register_fragments())
from shared.sx.handlers import auto_mount_fragment_handlers
auto_mount_fragment_handlers(app, "events")
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
@@ -171,19 +172,25 @@ def create_app() -> "Quart":
"markets": markets,
}
# Auto-mount all defpages with absolute paths
from shared.sx.pages import auto_mount_pages
auto_mount_pages(app, "events")
# Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as register_tickets
tickets_bp = register_tickets()
from shared.sx.pages import mount_pages
mount_pages(tickets_bp, "events", names=["my-tickets", "ticket-detail"])
app.register_blueprint(tickets_bp)
# Ticket admin — check-in interface (admin only)
from bp.ticket_admin.routes import register as register_ticket_admin
ticket_admin_bp = register_ticket_admin()
mount_pages(ticket_admin_bp, "events", names=["ticket-admin"])
app.register_blueprint(ticket_admin_bp)
# --- Pass defpage helper data to template context for layouts ---
@app.context_processor
async def inject_events_data():
return getattr(g, '_defpage_ctx', {})
# --- oEmbed endpoint ---
@app.get("/oembed")
async def oembed():

View File

@@ -3,6 +3,5 @@ from .calendar.routes import register as register_calendar
from .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets
from .page.routes import register as register_page
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
Blueprint, g, request,
)
@@ -15,23 +15,11 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _calendar_admin_main_panel_html
ctx = await get_template_context()
g.calendar_admin_content = _calendar_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["calendar-admin"])
@bp.get("/description/")
@require_admin
async def calendar_description_edit(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description_edit
html = render_calendar_description_edit(g.calendar)
from sxc.pages import render_calendar_description_edit
html = await render_calendar_description_edit(g.calendar)
return sx_response(html)
@@ -46,16 +34,16 @@ def register():
g.calendar.description = description
await g.s.flush()
from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar, oob=True)
from sxc.pages import render_calendar_description
html = await render_calendar_description(g.calendar, oob=True)
return sx_response(html)
@bp.get("/description/view/")
@require_admin
async def calendar_description_view(calendar_slug: str, **kwargs):
from sx.sx_components import render_calendar_description
html = render_calendar_description(g.calendar)
from sxc.pages import render_calendar_description
html = await render_calendar_description(g.calendar)
return sx_response(html)
return bp

View File

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

View File

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

View File

@@ -1,23 +1,8 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
)
from quart import Blueprint
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _entry_admin_main_panel_html
ctx = await get_template_context()
g.entry_admin_content = _entry_admin_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["entry-admin"])
return bp

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,8 @@
from __future__ import annotations
from quart import (
request, Blueprint, g
)
from quart import Blueprint
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from sx.sx_components import _day_admin_main_panel_html
g.day_admin_content = _day_admin_main_panel_html({})
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["day-admin"])
return bp

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone, date, timedelta
from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession
request, make_response, Blueprint, g, abort, session as qsession
)
from bp.calendar.services import get_visible_entries_for_period
@@ -123,7 +123,7 @@ def register():
- pending only for current user/session
"""
from shared.sx.page import get_template_context
from sx.sx_components import render_day_page, render_day_oob
from sxc.pages import render_day_page, render_day_oob
tctx = await get_template_context()
if not is_htmx_request():

View File

@@ -1 +0,0 @@
from .routes import register as register_fragments

View File

@@ -1,104 +0,0 @@
"""Events app fragment endpoints.
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
All handlers are defined declaratively in .sx files under
``events/sx/handlers/`` and dispatched via the sx handler registry.
container-cards and account-page remain as Python handlers because they
call domain service methods and return batched/conditional content, but
they use sx_call() for rendering (no Jinja templates).
"""
from __future__ import annotations
from quart import Blueprint, Response, g, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
from shared.sx.handlers import get_handler, execute_handler
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
# Fragment types that return HTML (comment-delimited batch)
_html_types = {"container-cards"}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
# 1. Check Python handlers first
handler = _handlers.get(fragment_type)
if handler is not None:
result = await handler()
ct = "text/html" if fragment_type in _html_types else "text/sx"
return Response(result, status=200, content_type=ct)
# 2. Check sx handler registry
handler_def = get_handler("events", fragment_type)
if handler_def is not None:
result = await execute_handler(
handler_def, "events", args=dict(request.args),
)
return Response(result, status=200, content_type="text/sx")
return Response("", status=200, content_type="text/sx")
# --- container-cards fragment: entries for blog listing cards -----------
# Returns text/html with <!-- card-widget:POST_ID --> comment markers
# so the blog consumer can split per-post fragments.
async def _container_cards_handler():
from sx.sx_components import render_fragment_container_cards
post_ids_raw = request.args.get("post_ids", "")
post_slugs_raw = request.args.get("post_slugs", "")
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()]
if not post_ids:
return ""
slug_map = {}
for i, pid in enumerate(post_ids):
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
return render_fragment_container_cards(batch, post_ids, slug_map)
_handlers["container-cards"] = _container_cards_handler
# --- account-page fragment: tickets or bookings panel ------------------
# Returns text/sx — the account app embeds this as sx source.
async def _account_page_handler():
from sx.sx_components import (
render_fragment_account_tickets,
render_fragment_account_bookings,
)
slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int)
if not user_id:
return ""
if slug == "tickets":
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
return render_fragment_account_tickets(tickets)
elif slug == "bookings":
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
return render_fragment_account_bookings(bookings)
return ""
_handlers["account-page"] = _account_page_handler
bp._fragment_handlers = _handlers
return bp

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
request, make_response, Blueprint, g
)
from .services.markets import (
@@ -21,18 +21,6 @@ def register():
async def inject_root():
return {}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
from shared.sx.page import get_template_context
from sx.sx_components import _markets_main_panel_html
ctx = await get_template_context()
g.markets_content = _markets_main_panel_html(ctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["events-markets"])
@bp.post("/new/")
@require_admin
async def create_market(**kwargs):
@@ -54,9 +42,9 @@ def register():
return await make_response(render_comp("error-inline", message=str(e)), 422)
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel
from sxc.pages import render_markets_list_panel
ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx))
return sx_response(await render_markets_list_panel(ctx))
@bp.delete("/<market_slug>/")
@require_admin
@@ -67,8 +55,8 @@ def register():
return await make_response("Market not found", 404)
from shared.sx.page import get_template_context
from sx.sx_components import render_markets_list_panel
from sxc.pages import render_markets_list_panel
ctx = await get_template_context()
return sx_response(render_markets_list_panel(ctx))
return sx_response(await render_markets_list_panel(ctx))
return bp

View File

@@ -8,7 +8,7 @@ Routes:
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from quart import Blueprint, g, request, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
@@ -47,7 +47,7 @@ def register() -> Blueprint:
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
from shared.sx.page import get_template_context
from sx.sx_components import render_page_summary_page, render_page_summary_oob
from sxc.pages import render_page_summary_page, render_page_summary_oob
ctx = await get_template_context()
if is_htmx_request():
@@ -65,7 +65,7 @@ def register() -> Blueprint:
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
from sx.sx_components import render_page_summary_cards
from sxc.pages import render_page_summary_cards
sx_src = await render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post)
return sx_response(sx_src)
@@ -106,8 +106,8 @@ def register() -> Blueprint:
if ident["session_id"] is not None:
frag_params["session_id"] = ident["session_id"]
from sx.sx_components import render_ticket_widget
widget_html = render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
from sxc.pages import render_ticket_widget
widget_html = await render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust")
mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False)
return sx_response(widget_html + (mini_html or ""))

View File

@@ -29,35 +29,14 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("slot", __name__, url_prefix='/<int:slot_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
slot_id = (request.view_args or {}).get("slot_id")
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
from quart import abort
abort(404)
g.slot = slot
calendar = getattr(g, "calendar", None)
from sx.sx_components import render_slot_main_panel
g.slot_content = render_slot_main_panel(slot, calendar)
@bp.context_processor
async def _inject_slot():
return {"slot": getattr(g, "slot", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slot-detail"])
@bp.get("/edit/")
@require_admin
async def get_edit(slot_id: int, **kwargs):
slot = await svc_get_slot(g.s, slot_id)
if not slot:
return await make_response("Not found", 404)
from sx.sx_components import render_slot_edit_form
return sx_response(render_slot_edit_form(slot, g.calendar))
from sxc.pages import render_slot_edit_form
return sx_response(await render_slot_edit_form(slot, g.calendar))
@bp.get("/view/")
@require_admin
@@ -65,8 +44,8 @@ def register():
slot = await svc_get_slot(g.s, slot_id)
if not slot:
return await make_response("Not found", 404)
from sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar))
from sxc.pages import render_slot_main_panel
return sx_response(await render_slot_main_panel(slot, g.calendar))
@bp.delete("/")
@require_admin
@@ -74,8 +53,8 @@ def register():
async def slot_delete(slot_id: int, **kwargs):
await svc_delete_slot(g.s, slot_id)
slots = await svc_list_slots(g.s, g.calendar.id)
from sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar))
from sxc.pages import render_slots_table
return sx_response(await render_slots_table(slots, g.calendar))
@bp.put("/")
@require_admin
@@ -156,8 +135,8 @@ def register():
}
), 422
from sx.sx_components import render_slot_main_panel
return sx_response(render_slot_main_panel(slot, g.calendar, oob=True))
from sxc.pages import render_slot_main_panel
return sx_response(await render_slot_main_panel(slot, g.calendar, oob=True))

View File

@@ -38,18 +38,6 @@ def register():
}
return {"slots": []}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
calendar = getattr(g, "calendar", None)
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
from sx.sx_components import render_slots_table
g.slots_content = render_slots_table(slots, calendar)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["slots-listing"])
@bp.post("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
@@ -122,20 +110,20 @@ def register():
# Success → re-render the slots table
slots = await svc_list_slots(g.s, g.calendar.id)
from sx.sx_components import render_slots_table
return sx_response(render_slots_table(slots, g.calendar))
from sxc.pages import render_slots_table
return sx_response(await render_slots_table(slots, g.calendar))
@bp.get("/add")
@require_admin
async def add_form(**kwargs):
from sx.sx_components import render_slot_add_form
return sx_response(render_slot_add_form(g.calendar))
from sxc.pages import render_slot_add_form
return sx_response(await render_slot_add_form(g.calendar))
@bp.get("/add-button")
@require_admin
async def add_button(**kwargs):
from sx.sx_components import render_slot_add_button
return sx_response(render_slot_add_button(g.calendar))
from sxc.pages import render_slot_add_button
return sx_response(await render_slot_add_button(g.calendar))
return bp

View File

@@ -14,10 +14,10 @@ import logging
from quart import (
Blueprint, g, request, make_response,
)
from sqlalchemy import select, func
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType
from models.calendars import CalendarEntry
from shared.browser.app.authz import require_admin
from shared.browser.app.redis_cacher import clear_cache
from shared.sx.helpers import sx_response
@@ -34,46 +34,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
# Get recent tickets
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
# Stats
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_admin_main_panel_html
ctx = await get_template_context()
g.ticket_admin_content = _ticket_admin_main_panel_html(ctx, tickets, stats)
@bp.get("/entry/<int:entry_id>/")
@require_admin
async def entry_tickets(entry_id: int):
@@ -93,8 +53,8 @@ def register() -> Blueprint:
tickets = await get_tickets_for_entry(g.s, entry_id)
from sx.sx_components import render_entry_tickets_admin
html = render_entry_tickets_admin(entry, tickets)
from sxc.pages import render_entry_tickets_admin
html = await render_entry_tickets_admin(entry, tickets)
return sx_response(html)
@bp.get("/lookup/")
@@ -109,11 +69,11 @@ def register() -> Blueprint:
)
ticket = await get_ticket_by_code(g.s, code)
from sx.sx_components import render_lookup_result
from sxc.pages import render_lookup_result
if not ticket:
return sx_response(render_lookup_result(None, "Ticket not found"))
return sx_response(await render_lookup_result(None, "Ticket not found"))
return sx_response(render_lookup_result(ticket, None))
return sx_response(await render_lookup_result(ticket, None))
@bp.post("/<code>/checkin/")
@require_admin
@@ -122,11 +82,11 @@ def register() -> Blueprint:
"""Check in a ticket by its code."""
success, error = await checkin_ticket(g.s, code)
from sx.sx_components import render_checkin_result
from sxc.pages import render_checkin_result
if not success:
return sx_response(render_checkin_result(False, error, None))
return sx_response(await render_checkin_result(False, error, None))
ticket = await get_ticket_by_code(g.s, code)
return sx_response(render_checkin_result(True, None, ticket))
return sx_response(await render_checkin_result(True, None, ticket))
return bp

View File

@@ -22,32 +22,6 @@ from shared.sx.helpers import sx_response
def register():
bp = Blueprint("ticket_type", __name__, url_prefix='/<int:ticket_type_id>')
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
ticket_type_id = (request.view_args or {}).get("ticket_type_id")
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
from quart import abort
abort(404)
g.ticket_type = ticket_type
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
va = request.view_args or {}
from sx.sx_components import render_ticket_type_main_panel
g.ticket_type_content = render_ticket_type_main_panel(
ticket_type, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
@bp.context_processor
async def _inject_ticket_type():
return {"ticket_type": getattr(g, "ticket_type", None)}
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-type-detail"])
@bp.get("/edit/")
@require_admin
async def get_edit(ticket_type_id: int, **kwargs):
@@ -56,9 +30,9 @@ def register():
if not ticket_type:
return await make_response("Not found", 404)
from sx.sx_components import render_ticket_type_edit_form
from sxc.pages import render_ticket_type_edit_form
va = request.view_args or {}
return sx_response(render_ticket_type_edit_form(
return sx_response(await render_ticket_type_edit_form(
ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -71,9 +45,9 @@ def register():
if not ticket_type:
return await make_response("Not found", 404)
from sx.sx_components import render_ticket_type_main_panel
from sxc.pages import render_ticket_type_main_panel
va = request.view_args or {}
return sx_response(render_ticket_type_main_panel(
return sx_response(await render_ticket_type_main_panel(
ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -138,9 +112,9 @@ def register():
return await make_response("Not found", 404)
# Return updated view with OOB flag
from sx.sx_components import render_ticket_type_main_panel
from sxc.pages import render_ticket_type_main_panel
va = request.view_args or {}
return sx_response(render_ticket_type_main_panel(
return sx_response(await render_ticket_type_main_panel(
ticket_type, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
oob=True,
@@ -157,9 +131,9 @@ def register():
# Re-render the ticket types list
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
from sx.sx_components import render_ticket_types_table
from sxc.pages import render_ticket_types_table
va = request.view_args or {}
return sx_response(render_ticket_types_table(
return sx_response(await render_ticket_types_table(
ticket_types, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))

View File

@@ -35,23 +35,6 @@ def register():
}
return {"ticket_types": []}
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
va = request.view_args or {}
from sx.sx_components import render_ticket_types_table
g.ticket_types_content = render_ticket_types_table(
ticket_types, entry, calendar,
va.get("day"), va.get("month"), va.get("year"),
)
from shared.sx.pages import mount_pages
mount_pages(bp, "events", names=["ticket-types-listing"])
@bp.post("/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
@@ -110,9 +93,9 @@ def register():
# Success → re-render the ticket types table
ticket_types = await svc_list_ticket_types(g.s, g.entry.id)
from sx.sx_components import render_ticket_types_table
from sxc.pages import render_ticket_types_table
va = request.view_args or {}
return sx_response(render_ticket_types_table(
return sx_response(await render_ticket_types_table(
ticket_types, g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -121,9 +104,9 @@ def register():
@require_admin
async def add_form(**kwargs):
"""Show the add ticket type form."""
from sx.sx_components import render_ticket_type_add_form
from sxc.pages import render_ticket_type_add_form
va = request.view_args or {}
return sx_response(render_ticket_type_add_form(
return sx_response(await render_ticket_type_add_form(
g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))
@@ -132,9 +115,9 @@ def register():
@require_admin
async def add_button(**kwargs):
"""Show the add ticket type button."""
from sx.sx_components import render_ticket_type_add_button
from sxc.pages import render_ticket_type_add_button
va = request.view_args or {}
return sx_response(render_ticket_type_add_button(
return sx_response(await render_ticket_type_add_button(
g.entry, g.calendar,
va.get("day"), va.get("month"), va.get("year"),
))

View File

@@ -24,8 +24,6 @@ from shared.sx.helpers import sx_response
from .services.tickets import (
create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count,
get_tickets_for_entry,
get_sold_ticket_count,
@@ -39,44 +37,6 @@ logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_my_tickets" in ep:
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
from shared.sx.page import get_template_context
from sx.sx_components import _tickets_main_panel_html
ctx = await get_template_context()
g.tickets_content = _tickets_main_panel_html(ctx, tickets)
elif "defpage_ticket_detail" in ep:
code = (request.view_args or {}).get("code")
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
from quart import abort
abort(404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
from quart import abort
abort(404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
from quart import abort
abort(404)
else:
from quart import abort
abort(404)
from shared.sx.page import get_template_context
from sx.sx_components import _ticket_detail_panel_html
ctx = await get_template_context()
g.ticket_detail_content = _ticket_detail_panel_html(ctx, ticket)
@bp.post("/buy/")
@clear_cache(tag="calendars", tag_scope="all")
async def buy_tickets():
@@ -166,8 +126,8 @@ def register() -> Blueprint:
summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
cart_count = summary.count + summary.calendar_count + summary.ticket_count
from sx.sx_components import render_buy_result
return sx_response(render_buy_result(entry, created, remaining, cart_count))
from sxc.pages import render_buy_result
return sx_response(await render_buy_result(entry, created, remaining, cart_count))
@bp.post("/adjust/")
@clear_cache(tag="calendars", tag_scope="all")
@@ -289,8 +249,8 @@ def register() -> Blueprint:
summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
cart_count = summary.count + summary.calendar_count + summary.ticket_count
from sx.sx_components import render_adjust_response
return sx_response(render_adjust_response(
from sxc.pages import render_adjust_response
return sx_response(await render_adjust_response(
entry, ticket_remaining, ticket_sold_count,
user_ticket_count, user_ticket_counts_by_type, cart_count,
))

57
events/queries.sx Normal file
View File

@@ -0,0 +1,57 @@
;; Events service — inter-service data queries
;;
;; Each defquery replaces a Python handler in bp/data/routes.py.
;; The (service ...) primitive calls the registered CalendarService method
;; with g.s (async session) + keyword args, and auto-converts DTOs to dicts.
(defquery pending-entries (&key user-id session-id)
"Calendar entries in pending state for a user or session."
(service "calendar" "pending-entries"
:user-id user-id :session-id session-id))
(defquery pending-tickets (&key user-id session-id)
"Tickets in pending state for a user or session."
(service "calendar" "pending-tickets"
:user-id user-id :session-id session-id))
(defquery entries-for-page (&key page-id user-id session-id)
"Calendar entries for a specific page."
(service "calendar" "entries-for-page"
:page-id page-id :user-id user-id :session-id session-id))
(defquery tickets-for-page (&key page-id user-id session-id)
"Tickets for a specific page."
(service "calendar" "tickets-for-page"
:page-id page-id :user-id user-id :session-id session-id))
(defquery entries-for-order (&key order-id)
"Calendar entries claimed by an order."
(service "calendar" "get-entries-for-order" :order-id order-id))
(defquery tickets-for-order (&key order-id)
"Tickets claimed by an order."
(service "calendar" "get-tickets-for-order" :order-id order-id))
(defquery entry-ids-for-content (&key content-type content-id)
"Entry IDs associated with a content item."
(service "calendar" "entry-ids-for-content"
:content-type content-type :content-id content-id))
(defquery associated-entries (&key content-type content-id page)
"Entries associated with content, paginated."
(let ((result (service "calendar" "associated-entries"
:content-type content-type :content-id content-id :page page)))
{"entries" (nth result 0) "has_more" (nth result 1)}))
(defquery calendars-for-container (&key type id)
"Calendars attached to a container (page, marketplace, etc)."
(service "calendar" "calendars-for-container"
:container-type type :container-id id))
(defquery visible-entries-for-period (&key calendar-id period-start period-end user-id session-id)
"Visible entries within a date range for a calendar."
(service "calendar" "visible-entries-for-period"
:calendar-id calendar-id
:period-start (parse-datetime period-start)
:period-end (parse-datetime period-end)
:user-id user-id :is-admin false :session-id session-id))

View File

@@ -0,0 +1,49 @@
;; Account-page fragment handler
;;
;; Renders tickets or bookings panel for the account dashboard.
;; slug=tickets → ticket list; slug=bookings → booking list.
(defhandler account-page (&key slug user_id)
(let ((uid (parse-int (or user_id "0"))))
(when (> uid 0)
(cond
(= slug "tickets")
(let ((tickets (service "calendar" "user-tickets" :user-id uid)))
(~events-frag-tickets-panel
:items (if (empty? tickets)
(~empty-state :message "No tickets yet."
:cls "text-sm text-stone-500")
(~events-frag-tickets-list
:items (<> (map (fn (t)
(~events-frag-ticket-item
:href (app-url "events"
(str "/tickets/" (get t "code") "/"))
:entry-name (get t "entry_name")
:date-str (format-date (get t "entry_start_at") "%d %b %Y, %H:%M")
:calendar-name (when (get t "calendar_name")
(span (str "\u00b7 " (get t "calendar_name"))))
:type-name (when (get t "ticket_type_name")
(span (str "\u00b7 " (get t "ticket_type_name"))))
:badge (~status-pill :status (or (get t "state") ""))))
tickets))))))
(= slug "bookings")
(let ((bookings (service "calendar" "user-bookings" :user-id uid)))
(~events-frag-bookings-panel
:items (if (empty? bookings)
(~empty-state :message "No bookings yet."
:cls "text-sm text-stone-500")
(~events-frag-bookings-list
:items (<> (map (fn (b)
(~events-frag-booking-item
:name (get b "name")
:date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M")
(if (get b "end_at")
(str " \u2013 " (format-date (get b "end_at") "%H:%M"))
""))
:calendar-name (when (get b "calendar_name")
(span (str "\u00b7 " (get b "calendar_name"))))
:cost-str (when (get b "cost")
(span (str "\u00b7 \u00a3" (get b "cost"))))
:badge (~status-pill :status (or (get b "state") ""))))
bookings))))))))))

View File

@@ -0,0 +1,38 @@
;; Container-cards fragment handler
;;
;; Returns HTML with <!-- card-widget:ID --> comment markers so the
;; blog consumer can split per-post fragments. Each post section
;; contains an events-frag-entries-widget with entry cards.
(defhandler container-cards (&key post_ids post_slugs)
(let ((ids (filter (fn (x) (> x 0))
(map parse-int
(filter (fn (s) (not (empty? s)))
(split (or post_ids "") ",")))))
(slugs (map trim
(split (or post_slugs "") ","))))
(when (not (empty? ids))
(let ((batch (service "calendar" "confirmed-entries-for-posts" :post-ids ids)))
(<> (map-indexed (fn (i pid)
(let ((entries (or (get batch pid) (list)))
(post-slug (or (nth slugs i) "")))
(<> (str "<!-- card-widget:" pid " -->")
(when (not (empty? entries))
(~events-frag-entries-widget
:cards (<> (map (fn (e)
(let ((time-str (str (format-date (get e "start_at") "%H:%M")
(if (get e "end_at")
(str " \u2013 " (format-date (get e "end_at") "%H:%M"))
""))))
(~events-frag-entry-card
:href (app-url "events"
(str "/" post-slug
"/" (get e "calendar_slug")
"/" (get e "start_at_year")
"/" (get e "start_at_month")
"/" (get e "start_at_day")
"/entries/" (get e "id") "/"))
:name (get e "name")
:date-str (format-date (get e "start_at") "%a, %b %d")
:time-str time-str))) entries))))
(str "<!-- /card-widget:" pid " -->")))) ids))))))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,89 +1,89 @@
;; Events pages — mounted on various nested blueprints
;; Events pages — auto-mounted with absolute paths
;; Calendar admin (mounted on calendar.admin bp)
;; Calendar admin
(defpage calendar-admin
:path "/"
:path "/<slug>/<calendar_slug>/admin/"
:auth :admin
:layout :events-calendar-admin
:content (calendar-admin-content))
:content (calendar-admin-content calendar-slug))
;; Day admin (mounted on day.admin bp)
;; Day admin
(defpage day-admin
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
:auth :admin
:layout :events-day-admin
:content (day-admin-content))
:content (day-admin-content calendar-slug year month day))
;; Slots listing (mounted on slots bp)
;; Slots listing
(defpage slots-listing
:path "/"
:path "/<slug>/<calendar_slug>/slots/"
:auth :public
:layout :events-slots
:content (slots-content))
:content (slots-content calendar-slug))
;; Slot detail (mounted on slot bp)
;; Slot detail
(defpage slot-detail
:path "/"
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
:auth :admin
:layout :events-slot
:content (slot-content))
:content (slot-content calendar-slug slot-id))
;; Entry detail (mounted on calendar_entry bp)
;; Entry detail
(defpage entry-detail
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
:auth :admin
:layout :events-entry
:content (entry-content)
:menu (entry-menu))
:content (entry-content calendar-slug entry-id)
:menu (entry-menu calendar-slug entry-id))
;; Entry admin (mounted on calendar_entry.admin bp)
;; Entry admin
(defpage entry-admin
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
:auth :admin
:layout :events-entry-admin
:content (entry-admin-content)
:content (entry-admin-content calendar-slug entry-id)
:menu (admin-menu))
;; Ticket types listing (mounted on ticket_types bp)
;; Ticket types listing
(defpage ticket-types-listing
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
:auth :public
:layout :events-ticket-types
:content (ticket-types-content)
:content (ticket-types-content calendar-slug entry-id year month day)
:menu (admin-menu))
;; Ticket type detail (mounted on ticket_type bp)
;; Ticket type detail
(defpage ticket-type-detail
:path "/"
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
:auth :admin
:layout :events-ticket-type
:content (ticket-type-content)
:content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
:menu (admin-menu))
;; My tickets (mounted on tickets bp)
;; My tickets
(defpage my-tickets
:path "/"
:path "/tickets/"
:auth :public
:layout :root
:content (tickets-content))
;; Ticket detail (mounted on tickets bp)
;; Ticket detail
(defpage ticket-detail
:path "/<code>/"
:path "/tickets/<code>/"
:auth :public
:layout :root
:content (ticket-detail-content))
:content (ticket-detail-content code))
;; Ticket admin dashboard (mounted on ticket_admin bp)
;; Ticket admin dashboard
(defpage ticket-admin
:path "/"
:path "/admin/tickets/"
:auth :admin
:layout :root
:content (ticket-admin-content))
;; Markets (mounted on markets bp)
;; Markets
(defpage events-markets
:path "/"
:path "/<slug>/markets/"
:auth :public
:layout :events-markets
:content (markets-content))

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