68 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:28:21 +00:00
bc7a4a5128 Add cross-service URL functions and rights to base_context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
blog_url, market_url, cart_url, events_url and g.rights were only
available as Jinja globals, not in the ctx dict passed to sexp
helper functions. This caused all cross-service links in the header
system (post title, cart badge, admin cog, admin nav items) to
produce relative URLs resolving to the current service domain.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:40:03 +00:00
3dd62bd9bf Bigger text in test dashboard + add deliberate failing test
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:34:19 +00:00
c926e5221d Fix test dashboard: use raw! for pre-rendered table rows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:32:40 +00:00
d62643312a Skip OAuth/auth for test service (public dashboard)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:24:07 +00:00
8852ab1108 Add test service to OAuth allowed clients
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:09:13 +00:00
1559c5c931 Add test runner dashboard service (test.rose-ash.com)
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Public Quart microservice that runs pytest against shared/tests/ and
shared/sexp/tests/, serving an HTMX-powered sexp-rendered dashboard
with pass/fail/running status, auto-refresh polling, and re-run button.
No database — results stored in memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:08:10 +00:00
00efbc2a35 Add unit test coverage for shared pure-logic modules (240 tests)
Track 1.1 of master plan: expand from sexp-only tests to cover
DTOs, HTTP signatures, HMAC auth, URL utilities, Jinja filters,
calendar helpers, config freeze, activity bus registry, parse
utilities, sexp helpers, error classes, and jinja bridge render API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:34:37 +00:00
6c44a5f3d0 Add app label to root header and auto-reload sexp templates in dev
Show current subdomain name (blog, cart, events, etc.) next to the site
title in the root header row. Remove the redundant second "cart" menu row
from cart overview and checkout error pages.

Add dev-mode hot-reload for sexp templates: track file mtimes and re-read
changed files per-request when RELOAD=true, so .sexp edits are picked up
without restarting services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:33:00 +00:00
6d43404b12 Consolidate post header/menu system into shared infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
Replace duplicated _post_header_html, _oob_header_html, and header-child
components across blog/events/market/errors with shared sexpr components
(~post-label, ~page-cart-badge, ~oob-header, ~header-child, ~error-content)
and shared Python helpers (post_header_html, oob_header_html,
header_child_html, error_content_html). App-specific logic (blog container-nav
wrapping, admin cog, events calendar links) preserved via thin wrappers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:06:18 +00:00
97c4e25ba7 Fix post-row link on 404: inject Jinja globals into error context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
base_context() doesn't include blog_url/cart_url/etc — those live in
Jinja globals. Without them call_url(ctx, "blog_url", ...) falls back
to a relative path instead of https://blog.rose-ash.com/...

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:47:03 +00:00
f1b7fdd37d Make rich 404 resilient to cross-service failures
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
Build a minimal context directly instead of relying on
get_template_context() which runs the full context processor chain
including cross-service fragment fetches. Each step (base_context,
fragments, post hydration) is independently try/excepted so the page
renders with whatever is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:36:11 +00:00
597b0d7a2f Fix relations nav_label URL bug and add rich 404 pages with headers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
The relations container-nav fragment was inserting nav_label (e.g.
"calendars", "markets") as a URL path segment, generating wrong links
like /the-village-hall/markets/suma/ instead of /the-village-hall/suma/.
The nav_label is for display only, not URL construction.

Also adds a rich 404 handler that shows site headers and post breadcrumb
when a slug can be resolved from the URL path. Falls back gracefully to
the minimal error page if context building fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:30:44 +00:00
ee41e30d5b Move payments admin from events to cart service
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
Payments config (SumUp credentials per page) is a cart concern since all
checkouts go through the cart service. Moves it from events.rose-ash.com
to cart.rose-ash.com/<page_slug>/admin/payments/ and adds a cart admin
overview page at /<page_slug>/admin/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:15:35 +00:00
5957bd8941 Move calendar blueprint to app level for correct URL routing
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
The calendar blueprint was nested under calendars (admin), making URLs
/{slug}/admin/{calendar_slug}/ instead of /{slug}/{calendar_slug}/.

Register calendar blueprint directly on the app and update all endpoint
references from calendars.calendar.* to calendar.* (37 in Python,
~50 in templates).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:50:13 +00:00
a8edc26a1d Add external flag to menu-row for cross-subdomain links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
Cross-subdomain hx-get breaks due to OAuth redirects. When external=true,
menu-row renders a plain <a href> without HTMX attributes, allowing
normal browser navigation.

Applied to post header links on events and market services which link
back to blog.rose-ash.com.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:46:47 +00:00
6a331e4ad8 Fix payments admin link to cart.rose-ash.com/{slug}/admin/payments/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Payments are managed by the cart service, not events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:37:41 +00:00
4a99bc56e9 Fix markets admin link to market.rose-ash.com/{slug}/admin/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Markets use the same admin pattern as calendars but on the market
subdomain, not the events subdomain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:34:54 +00:00
4fe5afe3e6 Move calendar management to /{slug}/admin/ and reserve slug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
- Change calendars blueprint prefix from /calendars to /admin
- Simplify routes from /calendars/ to / within blueprint
- Reserve admin, markets, payments, entries as calendar slugs
- Update blog admin nav link to /{slug}/admin/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:31:24 +00:00
efae7f5533 Fix calendars admin link to correct events URL path
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:28:12 +00:00
105f4c4679 Rewrite sprint plan: fit the task to the timescale
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Six 2-week sprints, each shipping one or two complete deliverables.
Not 20 weeks crammed into 2 — the right amount of work for the time.
Each sprint is valuable on its own. Stop after any and you've shipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:24:59 +00:00
a7cca2f720 Fix admin nav label: calendar → calendars
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:23:04 +00:00
8269977751 Add two-week sprint plan: 90% of the masterplan in 14 days
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
Ghost killed by day 5, sexp protocol running internally by day 8,
sexpr.js on every page by day 10. Cut Rust client, IPFS mesh, and
browser extension to later. Everything users touch runs on sexp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:21:13 +00:00
0df932bd94 Fix blog page title showing post name twice
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Stop concatenating post title into base_title in route context.
Build proper "Post Title — Site Title" format in meta component instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:20:44 +00:00
c220fe21d6 Add master plan: 9 tracks from stability to federated protocol
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
Schedules all existing plans into coherent 20-week roadmap with parallel
tracks: platform stability, decoupling, entities/relations, Ghost removal,
sexp pages, internal protocol, client-side runtime, native client, and
scalability. Critical path identified through Ghost removal as linchpin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:19:39 +00:00
f9d9697c67 Externalize sexp to .sexpr files + render() API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Replace all 676 inline sexp() string calls across 7 services with
render(component_name, **kwargs) calls backed by 46 external .sexpr
component definition files (587 defcomps total).

- Add render() function to shared/sexp/jinja_bridge.py
- Add load_service_components() helper and update load_sexp_dir() for *.sexpr
- Update parser keyword regex to support HTMX hx-on::event syntax
- Convert remaining inline HTML in route files to render() calls
- Add shared/sexp/templates/misc.sexp for cross-service utility components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:14:58 +00:00
f4c2f4b6b8 Add internal-first strategy for sexpr:// protocol development
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
Build and battle-test the protocol on the internal microservice mesh
before exposing it publicly. Current fetch_data/call_action/fetch_fragment
map directly to sexp verbs. Same protocol serves internal and public clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:16:35 +00:00
881ed2cdcc Add doc on sexp as microservice wire format
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Strongest near-term application: replace lossy dicts and opaque HTML
fragments with structured trees that are both inspectable and renderable.
Includes incremental migration path from current fetch_data/fetch_fragment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:11:05 +00:00
2ce2077d14 Add risks and pitfalls analysis for sexp protocol
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Honest assessment: adoption chicken-and-egg, security surface area,
accessibility gap, tooling desert, Lisp Curse fragmentation, Worse Is
Better problem, and mitigation strategy for each.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:08:44 +00:00
8cf834dd55 Add doc on how sexp protocol fundamentally changes the web
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m23s
Covers: APIs as separate concept disappearing, front-end framework
collapse, AI as first-class citizen, browser monopoly breaking,
content portability, client-server blur, computational governance,
and the Unix pipes analogy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:04:13 +00:00
4daecabf30 Add open verb system to unified sexp protocol spec
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
Verbs are no longer limited to HTTP's fixed seven methods — any symbol
is a valid verb. Domain-specific actions (reserve, publish, vote, bid)
read as natural language. Verb behaviour declared via schema endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:59:34 +00:00
19240c6ca3 Add cooperative compute mesh: client-as-node, GPU sharing, IPFS persistence
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
Members' Rust clients become full peer nodes — AP instances, IPFS nodes,
and artdag GPU workers. The relay server becomes a lightweight matchmaker
(message queue, pinning, peer directory) while all compute, rendering,
and content serving is distributed across members' own hardware. Back
to the original vision of the web: everyone has a server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:52:17 +00:00
3e29c2a334 Unify sexp protocol and ActivityPub extension into single spec
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
Merges sexpr-activitypub-extension.md and sexpr-protocol-and-tiered-clients.md
into sexpr-unified-protocol.md — recognising that browsing, federation, and
real-time updates are all the same thing: peers exchanging s-expressions on
a bidirectional stream. One format, one connection, one parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:44:39 +00:00
a70d3648ec Add sexp protocol spec and tiered client architecture plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
Defines three client tiers (browser HTML, browser extension with
sexpr.js, Rust native client) served from the same route handlers
via content negotiation. Includes native sexp:// protocol design
over QUIC, content-addressed caching, bidirectional streaming,
self-describing schema, and implementation plan from Phase 1
(Quart content negotiation) through Phase 7 (fallback gateway).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:40:18 +00:00
0d1ce92e52 Fix sexp parse errors: avoid literal parentheses in sexp string args
The sexp parser doesn't handle "(" and ")" as string literals
inside expressions. Use raw! with pre-formatted strings instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:20:41 +00:00
09b5a5b4f6 Convert account, orders, and federation sexp_components.py to pure sexp() calls
Eliminates all f-string HTML from the remaining three services,
completing the migration of all sexp_components.py files to the
s-expression rendering system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:15:17 +00:00
f0a100fd77 Convert cart sexp_components.py from f-string HTML to pure sexp() calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:08:36 +00:00
16da08ff05 Fix market and calendar URL routing
Market: blog links now use market_url('/{slug}/') instead of
events_url('/{slug}/markets/'), matching the market service's
actual route structure /<page_slug>/<market_slug>/.

Calendar: flatten route from /<slug>/calendars/<calendar_slug>/
to /<slug>/<calendar_slug>/ by changing the events app blueprint
prefix and moving listing routes to explicit /calendars/ paths.
Update all hardcoded calendar URL paths across blog and events
services (Python + Jinja templates).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:58:05 +00:00
5c6d83f474 Add sexp ActivityPub extension plan with implementation phases
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Defines a backwards-compatible AP extension using s-expressions as
the wire format: content negotiation, component discovery protocol,
WebSocket streaming, and a path to publishing as a FEP. Includes
bidirectional JSON-LD bridging for Mastodon/Pleroma compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:40:15 +00:00
da8a766e3f Convert all f-string HTML to sexp() in market/sexp/sexp_components.py
Eliminates every HTML tag from the market service's sexp component layer,
replacing f-string HTML with composable sexp() calls throughout ~30 functions
including product cards, filter panels, nav panels, product detail, meta tags,
cart controls, like buttons, and sentinel infinite-scroll elements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:38:53 +00:00
9fa3b8800c Add sexp-as-wire-format rationale for AI-driven systems
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Documents why s-expressions on the wire are a natural fit for
LLM agents: fewer tokens, no closing-tag errors, components as
tool calls, mutations as agent actions, content-addressed caching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:31:38 +00:00
f24292f99d Convert editor panel <script> block to sexp wrapper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Separate JS content from HTML tag — pass JS body into
(script (raw! js)) so zero raw HTML tags remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:27:48 +00:00
426 changed files with 26471 additions and 13137 deletions

View File

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

View File

@@ -0,0 +1,325 @@
# Split Cart into Microservices
## Context
The cart app currently owns too much: CartItem, Order/OrderItem, PageConfig, ContainerRelation, plus all checkout/payment logic. We're splitting it into 4 pieces:
1. **Relations service** — internal only, owns ContainerRelation
2. **Likes service** — internal only, unified generic likes replacing ProductLike + PostLike
3. **PageConfig → blog** — move to blog (which already owns pages)
4. **Orders service** — public (orders.rose-ash.com), owns Order/OrderItem + SumUp checkout
After the split, cart becomes a thin CartItem CRUD + inbox service.
---
## Phase 1: Relations Service (internal only)
### 1.1 Scaffold `relations/`
Create minimal internal-only app (no templates, no context_fn):
| File | Notes |
|------|-------|
| `relations/__init__.py` | Empty |
| `relations/path_setup.py` | Copy from cart |
| `relations/app.py` | `create_base_app("relations")`, register data + actions BPs only |
| `relations/services/__init__.py` | Empty `register_domain_services()` |
| `relations/models/__init__.py` | `from shared.models.container_relation import ContainerRelation` |
| `relations/bp/__init__.py` | Export `register_data`, `register_actions` |
| `relations/bp/data/routes.py` | Move `get-children` handler from `cart/bp/data/routes.py:175-198` |
| `relations/bp/actions/routes.py` | Move `attach-child` + `detach-child` from `cart/bp/actions/routes.py:112-153` |
| `relations/alembic.ini` | Copy from cart, adjust path |
| `relations/alembic/env.py` | MODELS=`["shared.models.container_relation"]`, TABLES=`{"container_relations"}` |
| `relations/alembic/versions/0001_initial.py` | Create `container_relations` table |
| `relations/Dockerfile` | Follow cart pattern, `COPY relations/ ./` |
| `relations/entrypoint.sh` | Standard pattern, db=`db_relations` |
### 1.2 Retarget callers (`"cart"` → `"relations"`)
| File | Lines | Change |
|------|-------|--------|
| `events/bp/calendars/services/calendars.py` | 74, 111, 121 | `call_action("cart", ...)``call_action("relations", ...)` |
| `blog/bp/menu_items/services/menu_items.py` | 83, 137, 141, 157 | Same |
| `shared/services/market_impl.py` | 96, 109, 133 | Same |
### 1.3 Clean up cart
- Remove `get-children` from `cart/bp/data/routes.py:175-198`
- Remove `attach-child`, `detach-child` from `cart/bp/actions/routes.py:112-153`
- Remove `"shared.models.container_relation"` and `"container_relations"` from `cart/alembic/env.py`
---
## Phase 2: Likes Service (internal only)
### 2.1 New unified model
Single `likes` table in `db_likes`:
```python
class Like(Base):
__tablename__ = "likes"
id: Mapped[int] (pk)
user_id: Mapped[int] (not null, indexed)
target_type: Mapped[str] (String 32, not null) # "product" or "post"
target_slug: Mapped[str | None] (String 255) # for products
target_id: Mapped[int | None] (Integer) # for posts
created_at, updated_at, deleted_at
UniqueConstraint("user_id", "target_type", "target_slug")
UniqueConstraint("user_id", "target_type", "target_id")
Index("ix_likes_target", "target_type", "target_slug")
```
Products use `target_type="product"`, `target_slug=slug`. Posts use `target_type="post"`, `target_id=post.id`.
### 2.2 Scaffold `likes/`
| File | Notes |
|------|-------|
| `likes/__init__.py` | Empty |
| `likes/path_setup.py` | Standard |
| `likes/app.py` | Internal-only, `create_base_app("likes")`, data + actions BPs |
| `likes/services/__init__.py` | Empty `register_domain_services()` |
| `likes/models/__init__.py` | Import Like |
| `likes/models/like.py` | Generic Like model (above) |
| `likes/bp/__init__.py` | Export register functions |
| `likes/bp/data/routes.py` | `is-liked`, `liked-slugs`, `liked-ids` |
| `likes/bp/actions/routes.py` | `toggle` action |
| `likes/alembic.ini` | Standard |
| `likes/alembic/env.py` | MODELS=`["likes.models.like"]`, TABLES=`{"likes"}` |
| `likes/alembic/versions/0001_initial.py` | Create `likes` table |
| `likes/Dockerfile` | Standard pattern |
| `likes/entrypoint.sh` | Standard, db=`db_likes` |
### 2.3 Data endpoints (`likes/bp/data/routes.py`)
- `is-liked`: params `user_id, target_type, target_slug/target_id``{"liked": bool}`
- `liked-slugs`: params `user_id, target_type``["slug1", "slug2"]`
- `liked-ids`: params `user_id, target_type``[1, 2, 3]`
### 2.4 Action endpoints (`likes/bp/actions/routes.py`)
- `toggle`: payload `{user_id, target_type, target_slug?, target_id?}``{"liked": bool}`
### 2.5 Retarget market app
**`market/bp/product/routes.py`** (like_toggle, ~line 119):
Replace `toggle_product_like(g.s, user_id, product_slug)` with:
```python
result = await call_action("likes", "toggle", payload={
"user_id": user_id, "target_type": "product", "target_slug": product_slug
})
liked = result["liked"]
```
**`market/bp/browse/services/db_backend.py`** (most complex):
- `db_product_full` / `db_product_full_id`: Replace `ProductLike` subquery with `fetch_data("likes", "is-liked", ...)`. Annotate `is_liked` after query.
- `db_products_nocounts` / `db_products_counts`: Fetch `liked_slugs` once via `fetch_data("likes", "liked-slugs", ...)`, filter `Product.slug.in_(liked_slugs)` for `?liked=true`, annotate `is_liked` post-query.
**Delete**: `toggle_product_like` from `market/bp/product/services/product_operations.py`
### 2.6 Retarget blog app
**`blog/bp/post/routes.py`** (like_toggle):
Replace `toggle_post_like(g.s, user_id, post_id)` with `call_action("likes", "toggle", payload={...})`.
**Delete**: `toggle_post_like` from `blog/bp/post/services/post_operations.py`
### 2.7 Remove old like models
- Remove `ProductLike` from `shared/models/market.py` (lines 118-131) + `Product.likes` relationship (lines 110-114)
- Remove `PostLike` from `shared/models/ghost_content.py` + `Post.likes` relationship
- Remove `product_likes` from market alembic TABLES
- Remove `post_likes` from blog alembic TABLES
---
## Phase 3: PageConfig → Blog
### 3.1 Replace blog proxy endpoints with direct DB queries
**`blog/bp/data/routes.py`** (lines 77-102): Replace the 3 proxy handlers that currently call `fetch_data("cart", ...)` with direct DB queries. Copy logic from `cart/bp/data/routes.py`:
- `page-config` (cart lines 114-134)
- `page-config-by-id` (cart lines 136-149)
- `page-configs-batch` (cart lines 151-172)
- `page-config-ensure` (cart lines 49-81) — add new
Also add the `_page_config_dict` helper (cart lines 203-213).
### 3.2 Move action to blog
**`blog/bp/actions/routes.py`** (~line 40): Replace `call_action("cart", "update-page-config", ...)` proxy with direct handler. Copy logic from `cart/bp/actions/routes.py:51-110`.
### 3.3 Blog callers become local
| File | Current | After |
|------|---------|-------|
| `blog/bp/post/admin/routes.py:34` | `fetch_data("cart", "page-config", ...)` | Direct DB query (blog now owns table) |
| `blog/bp/post/admin/routes.py:87,132` | `call_action("cart", "update-page-config", ...)` | Direct call to local handler |
| `blog/bp/post/services/markets.py:44` | `fetch_data("cart", "page-config", ...)` | Direct DB query |
| `blog/bp/blog/ghost_db.py:295` | `fetch_data("cart", "page-configs-batch", ...)` | Direct DB query |
### 3.4 Retarget cross-service callers (`"cart"` → `"blog"`)
| File | Change |
|------|--------|
| `cart/bp/cart/services/page_cart.py:181` | `fetch_data("cart", "page-configs-batch", ...)``fetch_data("blog", "page-configs-batch", ...)` |
| `cart/bp/cart/global_routes.py:274` | `fetch_data("cart", "page-config-by-id", ...)``fetch_data("blog", "page-config-by-id", ...)` |
(Note: `checkout.py:117` and `cart/app.py:177` already target `"blog"`)
### 3.5 Update blog alembic
**`blog/alembic/env.py`**: Add `"shared.models.page_config"` to MODELS and `"page_configs"` to TABLES.
### 3.6 Clean up cart
- Remove all `page-config*` handlers from `cart/bp/data/routes.py` (lines 49-172)
- Remove `update-page-config` from `cart/bp/actions/routes.py` (lines 50-110)
- Remove `"shared.models.page_config"` and `"page_configs"` from `cart/alembic/env.py`
---
## Phase 4: Orders Service (public, orders.rose-ash.com)
### 4.1 Scaffold `orders/`
| File | Notes |
|------|-------|
| `orders/__init__.py` | Empty |
| `orders/path_setup.py` | Standard |
| `orders/app.py` | Public app with `context_fn`, templates, fragments, page slug hydration |
| `orders/services/__init__.py` | `register_domain_services()` |
| `orders/models/__init__.py` | `from shared.models.order import Order, OrderItem` |
| `orders/bp/__init__.py` | Export all BPs |
| `orders/bp/order/` | Move from `cart/bp/order/` (single order: detail, pay, recheck) |
| `orders/bp/orders/` | Move from `cart/bp/orders/` (order list + pagination) |
| `orders/bp/checkout/routes.py` | Webhook + return routes from `cart/bp/cart/global_routes.py` |
| `orders/bp/data/routes.py` | Minimal |
| `orders/bp/actions/routes.py` | `create-order` action (called by cart during checkout) |
| `orders/bp/fragments/routes.py` | `account-nav-item` fragment (orders link) |
| `orders/templates/` | Move `_types/order/`, `_types/orders/`, checkout templates from cart |
| `orders/alembic.ini` | Standard |
| `orders/alembic/env.py` | MODELS=`["shared.models.order"]`, TABLES=`{"orders", "order_items"}` |
| `orders/alembic/versions/0001_initial.py` | Create `orders` + `order_items` tables |
| `orders/Dockerfile` | Standard, public-facing |
| `orders/entrypoint.sh` | Standard, db=`db_orders` |
### 4.2 Move checkout services to orders
**Move to `orders/services/`:**
- `checkout.py` — from `cart/bp/cart/services/checkout.py` (move: `create_order_from_cart`, `resolve_page_config`, `build_sumup_*`, `get_order_with_details`. Keep `find_or_create_cart_item` in cart.)
- `check_sumup_status.py` — from `cart/bp/cart/services/check_sumup_status.py`
**`clear_cart_for_order`** stays in cart as new action:
- Add `clear-cart-for-order` to `cart/bp/actions/routes.py`
- Orders calls `call_action("cart", "clear-cart-for-order", payload={user_id, session_id, page_post_id})`
### 4.3 `create-order` action endpoint (`orders/bp/actions/routes.py`)
Cart's `POST /checkout/` calls this:
```
Payload: {cart_items: [{product_id, product_title, product_slug, product_image,
product_special_price, product_regular_price, product_price_currency,
quantity, market_place_container_id}],
calendar_entries, tickets, user_id, session_id,
product_total, calendar_total, ticket_total,
page_post_id, redirect_url, webhook_base_url}
Returns: {order_id, sumup_hosted_url, page_config_id, sumup_reference, description}
```
### 4.4 Refactor cart's checkout route
`cart/bp/cart/global_routes.py` `POST /checkout/`:
1. Load local cart data (get_cart, calendar entries, tickets, totals)
2. Serialize cart items to dicts
3. `result = await call_action("orders", "create-order", payload={...})`
4. Redirect to `result["sumup_hosted_url"]`
Same for page-scoped checkout in `cart/bp/cart/page_routes.py`.
### 4.5 Move webhook + return routes to orders
- `POST /checkout/webhook/<order_id>/``orders/bp/checkout/routes.py`
- `GET /checkout/return/<order_id>/``orders/bp/checkout/routes.py`
- SumUp redirect/webhook URLs must now point to orders.rose-ash.com
### 4.6 Move order list/detail routes
- `cart/bp/order/``orders/bp/order/`
- `cart/bp/orders/``orders/bp/orders/`
### 4.7 Move startup reconciliation
`_reconcile_pending_orders` from `cart/app.py:209-265``orders/app.py`
### 4.8 Clean up cart
- Remove `cart/bp/order/`, `cart/bp/orders/`
- Remove checkout webhook/return from `cart/bp/cart/global_routes.py`
- Remove `_reconcile_pending_orders` from `cart/app.py`
- Remove order templates from `cart/templates/`
- Remove `"shared.models.order"` and `"orders", "order_items"` from `cart/alembic/env.py`
---
## Phase 5: Infrastructure (applies to all new services)
### 5.1 docker-compose.yml
Add 3 new services (relations, likes, orders) with own DATABASE_URL (db_relations, db_likes, db_orders), own REDIS_URL (Redis DB 7, 8, 9).
Add to `x-app-env`:
```yaml
INTERNAL_URL_RELATIONS: http://relations:8000
INTERNAL_URL_LIKES: http://likes:8000
INTERNAL_URL_ORDERS: http://orders:8000
APP_URL_ORDERS: https://orders.rose-ash.com
```
### 5.2 docker-compose.dev.yml
Add all 3 services with dev volumes (ports 8008, 8009, 8010).
Add to `x-sibling-models` for all 3 new services.
### 5.3 deploy.sh
Add `relations likes orders` to APPS list.
### 5.4 Caddyfile (`/root/caddy/Caddyfile`)
Add only orders (public):
```
orders.rose-ash.com { reverse_proxy rose-ash-dev-orders-1:8000 }
```
### 5.5 shared/infrastructure/factory.py
Add to model import loop: `"relations.models", "likes.models", "orders.models"`
### 5.6 shared/infrastructure/urls.py
Add `orders_url(path)` helper.
### 5.7 All existing Dockerfiles
Add sibling model COPY lines for the 3 new services to every existing Dockerfile (blog, market, cart, events, federation, account).
### 5.8 CLAUDE.md
Update project structure and add notes about the new services.
---
## Data Migration (one-time, run before code switch)
1. `container_relations` from `db_cart``db_relations`
2. `product_likes` from `db_market` + `post_likes` from `db_blog``db_likes.likes`
3. `page_configs` from `db_cart``db_blog`
4. `orders` + `order_items` from `db_cart``db_orders`
Use `pg_dump`/`pg_restore` or direct SQL for migration.
---
## Post-Split Cart State
After all 4 phases, cart owns only:
- **Model**: CartItem (table in db_cart)
- **Alembic**: `cart_items` only
- **Data endpoints**: `cart-summary`, `cart-items`
- **Action endpoints**: `adopt-cart-for-user`, `clear-cart-for-order` (new)
- **Inbox handlers**: Add/Remove/Update `rose:CartItem`
- **Public routes**: cart overview, page cart, add-to-cart, quantity, delete
- **Fragments**: `cart-mini`
- **Checkout**: POST /checkout/ (creates order via `call_action("orders", "create-order")`, redirects to SumUp)
---
## Verification
1. **Relations**: Blog attach/detach marketplace to page; events attach/detach calendar
2. **Likes**: Toggle product like on market page; toggle post like on blog; `?liked=true` filter
3. **PageConfig**: Blog admin page config update; cart checkout resolves page config from blog
4. **Orders**: Add to cart → checkout → SumUp redirect → webhook → order paid; order list/detail on orders.rose-ash.com
5. No remaining `call_action("cart", "attach-child|detach-child|update-page-config")`
6. No remaining `fetch_data("cart", "page-config*|get-children")`
7. Cart alembic only manages `cart_items` table

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,435 +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
from typing import Any
from shared.sexp.jinja_bridge import sexp
from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page,
)
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _auth_nav_html(ctx: dict) -> str:
"""Auth section desktop nav items."""
html = sexp(
'(~nav-link :href h :label "newsletters" :select-colours sc)',
h=call_url(ctx, "account_url", "/newsletters/"),
sc=ctx.get("select_colours", ""),
)
account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html:
html += account_nav_html
return html
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row."""
return sexp(
'(~menu-row :id "auth-row" :level 1 :colour "sky"'
' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
' :nav-html nh :child-id "auth-header-child" :oob oob)',
lh=call_url(ctx, "account_url", "/"),
nh=_auth_nav_html(ctx),
oob=oob,
)
def _auth_nav_mobile_html(ctx: dict) -> str:
"""Mobile nav menu for auth section."""
html = sexp(
'(~nav-link :href h :label "newsletters" :select-colours sc)',
h=call_url(ctx, "account_url", "/newsletters/"),
sc=ctx.get("select_colours", ""),
)
account_nav_html = ctx.get("account_nav_html", "")
if account_nav_html:
html += account_nav_html
return html
# ---------------------------------------------------------------------------
# Account dashboard (GET /)
# ---------------------------------------------------------------------------
def _account_main_panel_html(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", "")
parts = ['<div class="w-full max-w-3xl mx-auto px-4 py-6">',
'<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">']
if error:
parts.append(
f'<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">{error}</div>'
)
# Account header with logout
parts.append('<div class="flex items-center justify-between"><div>')
parts.append('<h1 class="text-xl font-semibold tracking-tight">Account</h1>')
if user:
parts.append(f'<p class="text-sm text-stone-500 mt-1">{user.email}</p>')
if user.name:
parts.append(f'<p class="text-sm text-stone-600">{user.name}</p>')
parts.append('</div>')
parts.append(
f'<form action="/auth/logout/" method="post">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<button type="submit" class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition">'
f'<i class="fa-solid fa-right-from-bracket text-xs"></i> Sign out</button></form>'
)
parts.append('</div>')
# Labels
if user and hasattr(user, "labels") and user.labels:
parts.append('<div><h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>')
parts.append('<div class="flex flex-wrap gap-2">')
for label in user.labels:
parts.append(
f'<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">'
f'{label.name}</span>'
)
parts.append('</div></div>')
parts.append('</div></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Newsletters (GET /newsletters/)
# ---------------------------------------------------------------------------
def _newsletter_toggle_html(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 (
f'<div id="nl-{nid}" class="flex items-center">'
f'<button hx-post="{toggle_url}"'
f' hx-headers=\'{{"X-CSRFToken": "{csrf_token}"}}\''
f' hx-target="#nl-{nid}" hx-swap="outerHTML"'
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}"'
f' role="switch" aria-checked="{checked}">'
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}"></span>'
f'</button></div>'
)
def _newsletters_panel_html(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()
parts = ['<div class="w-full max-w-3xl mx-auto px-4 py-6">',
'<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">',
'<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>']
if newsletter_list:
parts.append('<div class="divide-y divide-stone-100">')
for item in newsletter_list:
nl = item["newsletter"]
un = item.get("un")
parts.append('<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">')
parts.append(f'<div class="min-w-0 flex-1"><p class="text-sm font-medium text-stone-800">{nl.name}</p>')
if nl.description:
parts.append(f'<p class="text-xs text-stone-500 mt-0.5 truncate">{nl.description}</p>')
parts.append('</div><div class="ml-4 flex-shrink-0">')
if un:
parts.append(_newsletter_toggle_html(un, account_url_fn, csrf))
else:
# No subscription yet — show off toggle
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
parts.append(
f'<div id="nl-{nl.id}" class="flex items-center">'
f'<button hx-post="{toggle_url}"'
f' hx-headers=\'{{"X-CSRFToken": "{csrf}"}}\''
f' hx-target="#nl-{nl.id}" hx-swap="outerHTML"'
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"'
f' role="switch" aria-checked="false">'
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>'
f'</button></div>'
)
parts.append('</div></div>')
parts.append('</div>')
else:
parts.append('<p class="text-sm text-stone-500">No newsletters available.</p>')
parts.append('</div></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# 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", "")
parts = ['<div class="py-8 max-w-md mx-auto">',
'<h1 class="text-2xl font-bold mb-6">Sign in</h1>']
if error:
parts.append(
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
)
action = url_for("auth.start_login")
parts.append(
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
f'<input type="email" name="email" id="email" value="{email}" required autofocus'
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Send magic link</button></form>'
)
parts.append('</div>')
return "".join(parts)
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", "")
parts = ['<div class="py-8 max-w-md mx-auto">',
'<h1 class="text-2xl font-bold mb-6">Authorize device</h1>',
'<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>']
if error:
parts.append(
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
)
action = url_for("auth.device_submit")
parts.append(
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<div><label for="code" class="block text-sm font-medium mb-1">Device code</label>'
f'<input type="text" name="code" id="code" value="{code}" placeholder="XXXX-XXXX"'
f' required autofocus maxlength="9" autocomplete="off" spellcheck="false"'
f' class="w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Authorize</button></form>'
)
parts.append('</div>')
return "".join(parts)
def _device_approved_content() -> str:
"""Device approved success content."""
return (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Device authorized</h1>'
'<p class="text-stone-600">You can close this window and return to your terminal.</p>'
'</div>'
)
# ---------------------------------------------------------------------------
# Public API: Account dashboard
# ---------------------------------------------------------------------------
async def render_account_page(ctx: dict) -> str:
"""Full page: account dashboard."""
main = _account_main_panel_html(ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
a=_auth_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
async def render_account_oob(ctx: dict) -> str:
"""OOB response for account dashboard."""
main = _account_main_panel_html(ctx)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
# ---------------------------------------------------------------------------
# Public API: Newsletters
# ---------------------------------------------------------------------------
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
"""Full page: newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
a=_auth_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
"""OOB response for newsletters."""
main = _newsletters_panel_html(ctx, newsletter_list)
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs,
content_html=main,
menu_html=_auth_nav_mobile_html(ctx))
# ---------------------------------------------------------------------------
# Public API: Fragment pages
# ---------------------------------------------------------------------------
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content."""
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
a=_auth_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr,
content_html=page_fragment_html,
menu_html=_auth_nav_mobile_html(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
"""OOB response for fragment pages."""
oobs = (
_auth_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs,
content_html=page_fragment_html,
menu_html=_auth_nav_mobile_html(ctx))
# ---------------------------------------------------------------------------
# Public API: Auth pages (login, device)
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: login form."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_login_page_content(ctx),
meta_html='<title>Login \u2014 Rose Ash</title>')
async def render_device_page(ctx: dict) -> str:
"""Full page: device authorization form."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_page_content(ctx),
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
async def render_device_approved_page(ctx: dict) -> str:
"""Full page: device approved."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_device_approved_content(),
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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_html = ""
if email_error:
error_html = (
f'<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">'
f'{escape(email_error)}</div>'
)
return (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Check your email</h1>'
f'<p class="text-stone-600 mb-2">We sent a sign-in link to <strong>{escape(email)}</strong>.</p>'
'<p class="text-stone-500 text-sm">Click the link in the email to sign in. The link expires in 15 minutes.</p>'
f'{error_html}</div>'
)
async def render_check_email_page(ctx: dict) -> str:
"""Full page: check email after magic link sent."""
email = ctx.get("email", "")
email_error = ctx.get("email_error")
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr,
content_html=_check_email_content(email, email_error),
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# Public API: Fragment renderers for POST handlers
# ---------------------------------------------------------------------------
def render_newsletter_toggle_html(un) -> str:
"""Render a newsletter toggle switch for POST response."""
from shared.browser.app.csrf import generate_csrf_token
return _newsletter_toggle_html(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p,
generate_csrf_token())
def render_newsletter_toggle(un) -> str:
"""Render a newsletter toggle switch for POST response (uses account_url)."""
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:
# Fallback: construct URL directly
from shared.infrastructure.urls import account_url
account_url_fn = account_url
return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token())

58
account/sx/auth.sx Normal file
View File

@@ -0,0 +1,58 @@
;; Auth page components (login, device, check email)
(defcomp ~account-login-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
error)))
(defcomp ~account-login-form (&key error action csrf-token email)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Sign in")
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
(button :type "submit"
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Send magic link"))))
(defcomp ~account-device-error (&key error)
(when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
error)))
(defcomp ~account-device-form (&key error action csrf-token code)
(div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Authorize device")
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
error
(form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(div
(label :for "code" :class "block text-sm font-medium mb-1" "Device code")
(input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"
:required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"
:class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))
(button :type "submit"
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Authorize"))))
(defcomp ~account-device-approved ()
(div :class "py-8 max-w-md mx-auto text-center"
(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.")))
(defcomp ~account-check-email-error (&key error)
(when error
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
error)))
(defcomp ~account-check-email (&key email error)
(div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Check your email")
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
error))

48
account/sx/dashboard.sx Normal file
View File

@@ -0,0 +1,48 @@
;; Account dashboard components
(defcomp ~account-error-banner (&key error)
(when error
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
error)))
(defcomp ~account-user-email (&key email)
(when email
(p :class "text-sm text-stone-500 mt-1" email)))
(defcomp ~account-user-name (&key name)
(when name
(p :class "text-sm text-stone-600" name)))
(defcomp ~account-logout-form (&key csrf-token)
(form :action "/auth/logout/" :method "post"
(input :type "hidden" :name "csrf_token" :value csrf-token)
(button :type "submit"
:class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
(i :class "fa-solid fa-right-from-bracket text-xs") " Sign out")))
(defcomp ~account-label-item (&key name)
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
name))
(defcomp ~account-labels-section (&key items)
(when items
(div
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
(div :class "flex flex-wrap gap-2" items))))
(defcomp ~account-main-panel (&key error email name logout labels)
(div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"
error
(div :class "flex items-center justify-between"
(div
(h1 :class "text-xl font-semibold tracking-tight" "Account")
email
name)
logout)
labels)))
;; Header child wrapper
(defcomp ~account-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
inner))

37
account/sx/newsletters.sx Normal file
View File

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

392
account/sx/sx_components.py Normal file
View File

@@ -0,0 +1,392 @@
"""
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, header_child_sx, oob_page_sx,
)
# Load account-specific .sx components at import time
load_service_components(os.path.dirname(os.path.dirname(__file__)))
# ---------------------------------------------------------------------------
# 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-off",
id=f"nl-{nid}", url=toggle_url,
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
target=f"#nl-{nid}",
)
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("account-login-error", error=error) if error else ""
return sx_call(
"account-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
# ---------------------------------------------------------------------------
async def render_account_page(ctx: dict) -> str:
"""Full page: account dashboard."""
main = _account_main_panel_sx(ctx)
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_account_oob(ctx: dict) -> str:
"""OOB response for account dashboard."""
main = _account_main_panel_sx(ctx)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
# Public API: Newsletters
# ---------------------------------------------------------------------------
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
"""Full page: newsletters."""
main = _newsletters_panel_sx(ctx, newsletter_list)
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
content=main,
menu=_auth_nav_mobile_sx(ctx))
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
"""OOB response for newsletters."""
main = _newsletters_panel_sx(ctx, newsletter_list)
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page_sx(oobs=oobs,
content=main,
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
# Public API: Fragment pages
# ---------------------------------------------------------------------------
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
"""Full page: fragment-provided content."""
hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")"
return full_page_sx(ctx, header_rows=header_rows,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sx(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
"""OOB response for fragment pages."""
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
return oob_page_sx(oobs=oobs,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
menu=_auth_nav_mobile_sx(ctx))
# ---------------------------------------------------------------------------
# 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(
"account-check-email-error", error=str(escape(email_error))
) if email_error else ""
return sx_call(
"account-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,9 +1,9 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
sx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
sx-target="#nl-{{ un.newsletter_id }}"
sx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

178
blog/sx/admin.sx Normal file
View File

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

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

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

60
blog/sx/detail.sx Normal file
View File

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

54
blog/sx/editor.sx Normal file
View File

@@ -0,0 +1,54 @@
;; Blog editor components
(defcomp ~blog-editor-error (&key error)
(div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700"
(strong "Save failed:") " " error))
(defcomp ~blog-editor-form (&key csrf title-placeholder create-label)
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
(div :id "feature-image-empty"
(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 "relative hidden"
(img :id "feature-image-preview" :src "" :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 ""
: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 "" :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")
(div :id "lexical-editor" :class "relative w-full bg-transparent")
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
(select :name "status"
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
(option :value "draft" :selected t "Draft")
(option :value "published" "Published"))
(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))))
(defcomp ~blog-editor-styles (&key css-href)
(<> (link :rel "stylesheet" :href css-href)
(style
"#lexical-editor { display: flow-root; }"
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
(defcomp ~blog-editor-scripts (&key js-src init-js)
(<> (script :src js-src) (script init-js)))

65
blog/sx/filters.sx Normal file
View File

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

24
blog/sx/header.sx Normal file
View File

@@ -0,0 +1,24 @@
;; Blog header components
(defcomp ~blog-header-label ()
(div))
(defcomp ~blog-container-nav (&key container-nav)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" container-nav))
(defcomp ~blog-admin-label ()
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label is-selected select-colours)
(div :class "relative nav-group"
(a :href href
:aria-selected (when is-selected "true")
:class (str (or nav-btn-class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3") " " (or select-colours ""))
label)))
(defcomp ~blog-sub-settings-label (&key icon label)
(<> (i :class icon :aria-hidden "true") " " label))
(defcomp ~blog-sub-admin-label (&key icon label)
(<> (i :class icon :aria-hidden "true") (div label)))

79
blog/sx/index.sx Normal file
View File

@@ -0,0 +1,79 @@
;; Blog index components
(defcomp ~blog-end-of-results ()
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-8"
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-8 text-stone-400"
(i :class "fa fa-exclamation-triangle text-2xl")
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-2"
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
(defcomp ~blog-page-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"))
(defcomp ~blog-no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
(defcomp ~blog-list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~blog-tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ "on click js localStorage.removeItem('blog_view') end" list-svg)
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ "on click js localStorage.setItem('blog_view','tile') end" tile-svg)))
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " posts-cls) "Posts")
(a :href pages-href :sx-get pages-href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
(defcomp ~blog-main-panel-pages (&key tabs cards)
(<> tabs
(div :class "max-w-full px-3 py-3 space-y-3" cards)
(div :class "pb-8")))
(defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards)
(<> tabs
toggle
(div :class grid-cls cards)
(div :class "pb-8")))
(defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter)
(<> search
action-buttons
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
tag-groups-filter
authors-filter)
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML")))

67
blog/sx/nav.sx Normal file
View File

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

113
blog/sx/settings.sx Normal file
View File

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

1911
blog/sx/sx_components.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
{% for entry in entry_list %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
{% set _entry_path = '/' + post.slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}"
@@ -29,10 +29,10 @@
{# Load more entries one at a time until container is full #}
{% if has_more_entries %}
<div id="entries-load-sentinel-{{ current_page }}"
hx-get="{{ url_for('blog.post.get_entries', slug=post.slug, page=current_page + 1) }}"
hx-trigger="intersect once"
hx-swap="beforebegin"
_="on htmx:afterRequest trigger scroll on #associated-entries-container"
sx-get="{{ url_for('blog.post.get_entries', slug=post.slug, page=current_page + 1) }}"
sx-trigger="intersect once"
sx-swap="beforebegin"
sx-on:afterSwap="document.querySelector('#associated-entries-container').dispatchEvent(new Event('scroll'))"
class="flex-shrink-0 w-1">
</div>
{% endif %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
{% import 'macros/links.html' as links %}
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="{{styles.nav_button}}">
calendars
<a href="{{ events_url('/' + post.slug + '/calendar/') }}" class="{{styles.nav_button}}">
calendar
</a>
</div>
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/markets/') }}" class="{{styles.nav_button}}">
<a href="{{ market_url('/' + post.slug + '/') }}" class="{{styles.nav_button}}">
markets
</a>
</div>

View File

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

View File

@@ -5,7 +5,7 @@
{% call nav_entries_oob(has_items) %}
{% if associated_entries and associated_entries.entries %}
{% for entry in associated_entries.entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
{% set _entry_path = '/' + post.slug + '/' +entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}">
@@ -22,7 +22,7 @@
{% endif %}
{% if calendars %}
{% for calendar in calendars %}
{% set local_href=events_url('/' + post.slug + '/calendars/' + calendar.slug + '/') %}
{% set local_href=events_url('/' + post.slug + '/' +calendar.slug + '/') %}
<a
href="{{ local_href }}"
class="{{styles.nav_button_less_pad}}">

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_data-row', oob=oob) %}
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
<a href="{{ events_url('/' + post.slug + '/calendar/') }}" class="flex gap-2 px-3 py-2 rounded whitespace-normal text-center break-words leading-snug">
<i class="fa fa-database" aria-hidden="true"></i>
<div>data</div>
</a>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
from decimal import Decimal
from pathlib import Path
@@ -16,6 +16,7 @@ from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_page_admin,
register_fragments,
register_actions,
register_data,
@@ -49,7 +50,7 @@ async def cart_context() -> dict:
- cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html)
- nav_tree_html: fetched from blog as fragment
- nav_tree: fetched from blog as fragment
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
@@ -72,14 +73,14 @@ async def cart_context() -> dict:
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
cart_mini, auth_menu, nav_tree = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "cart", "path": request.path}),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
ctx["cart_mini"] = cart_mini
ctx["auth_menu"] = auth_menu
ctx["nav_tree"] = nav_tree
# Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or []
@@ -195,6 +196,12 @@ def create_app() -> "Quart":
url_prefix="/",
)
# Page admin at /<page_slug>/admin/ (before page_cart catch-all)
app.register_blueprint(
register_page_admin(),
url_prefix="/<page_slug>/admin",
)
# Page cart at /<page_slug>/ (dynamic, matched last)
app.register_blueprint(
register_page_cart(url_prefix="/"),

View File

@@ -1,6 +1,7 @@
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
from quart import (
make_response, Blueprint, g, request
)
from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.sx.helpers import sx_response
def register():
bp = Blueprint("page_admin", __name__)
@bp.get("/")
@require_admin
async def admin(**kwargs):
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_admin_page, render_cart_admin_oob
ctx = await get_template_context()
page_post = getattr(g, "page_post", None)
if not is_htmx_request():
html = await render_cart_admin_page(ctx, page_post)
return await make_response(html)
else:
sx_src = await render_cart_admin_oob(ctx, page_post)
return sx_response(sx_src)
@bp.get("/payments/")
@require_admin
async def payments(**kwargs):
from shared.sx.page import get_template_context
from sx.sx_components import render_cart_payments_page, render_cart_payments_oob
ctx = await get_template_context()
page_post = getattr(g, "page_post", None)
if not is_htmx_request():
html = await render_cart_payments_page(ctx, page_post)
return await make_response(html)
else:
sx_src = await render_cart_payments_oob(ctx, page_post)
return sx_response(sx_src)
@bp.put("/payments/")
@require_admin
async def update_sumup(**kwargs):
"""Update SumUp credentials for this page (writes to blog's db_blog)."""
page_post = getattr(g, "page_post", None)
if not page_post:
return await make_response("Page not found", 404)
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip()
payload = {
"container_type": "page",
"container_id": page_post.id,
"sumup_merchant_code": merchant_code,
"sumup_checkout_prefix": checkout_prefix,
}
if api_key:
payload["sumup_api_key"] = api_key
await call_action("blog", "update-page-config", payload=payload)
# Re-fetch page config to get fresh data
from types import SimpleNamespace
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": page_post.id},
required=False,
)
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
ctx = await get_template_context()
html = render_cart_payments_panel(ctx)
return sx_response(html)
return bp

View File

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

View File

@@ -1,858 +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
from typing import Any
from shared.sexp.jinja_bridge import sexp
from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page,
)
from shared.infrastructure.urls import market_product_url, cart_url
# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------
def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the cart section header row."""
return sexp(
'(~menu-row :id "cart-row" :level 1 :colour "sky"'
' :link-href lh :link-label "cart" :icon "fa fa-shopping-cart"'
' :child-id "cart-header-child" :oob oob)',
lh=call_url(ctx, "cart_url", "/"),
oob=oob,
)
def _page_cart_header_html(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]
img_html = ""
if page_post and page_post.feature_image:
img_html = (
f'<img src="{page_post.feature_image}"'
f' class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
)
label_html = f'{img_html}<span>{title}</span>'
nav_html = sexp(
'(a :href h :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"'
' (raw! i) "All carts")',
h=call_url(ctx, "cart_url", "/"),
i='<i class="fa fa-arrow-left text-xs" aria-hidden="true"></i>',
)
return sexp(
'(~menu-row :id "page-cart-row" :level 2 :colour "sky"'
' :link-href lh :link-label-html llh :nav-html nh :oob oob)',
lh=call_url(ctx, "cart_url", f"/{slug}/"),
llh=label_html,
nh=nav_html,
oob=oob,
)
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build the account section header row (for orders)."""
return sexp(
'(~menu-row :id "auth-row" :level 1 :colour "sky"'
' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
' :child-id "auth-header-child" :oob oob)',
lh=call_url(ctx, "account_url", "/"),
oob=oob,
)
def _orders_header_html(ctx: dict, list_url: str) -> str:
"""Build the orders section header row."""
return sexp(
'(~menu-row :id "orders-row" :level 2 :colour "sky"'
' :link-href lh :link-label "Orders" :icon "fa fa-gbp"'
' :child-id "orders-header-child")',
lh=list_url,
)
# ---------------------------------------------------------------------------
# Cart overview
# ---------------------------------------------------------------------------
def _page_group_card_html(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
badges = []
if product_count > 0:
s = "s" if product_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-box-open" aria-hidden="true"></i> {product_count} item{s}</span>'
)
if calendar_count > 0:
s = "s" if calendar_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-calendar" aria-hidden="true"></i> {calendar_count} booking{s}</span>'
)
if ticket_count > 0:
s = "s" if ticket_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-ticket" aria-hidden="true"></i> {ticket_count} ticket{s}</span>'
)
badges_html = '<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">' + "".join(badges) + '</div>'
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_url = call_url(ctx, "cart_url", f"/{slug}/")
if feature_image:
img = f'<img src="{feature_image}" alt="{title}" class="h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0">'
else:
img = '<div class="h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"><i class="fa fa-store text-stone-400 text-xl" aria-hidden="true"></i></div>'
mp_name = ""
mp_sub = ""
if market_place:
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
mp_sub = f'<p class="text-xs text-stone-500 truncate">{title}</p>'
display_title = mp_name or title
return (
f'<a href="{cart_url}" class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5">'
f'<div class="flex items-start gap-4">{img}'
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">{display_title}</h3>{mp_sub}{badges_html}</div>'
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">&pound;{total:.2f}</div>'
f'<div class="mt-1 text-xs text-emerald-700 font-medium">View cart &rarr;</div></div></div></a>'
)
else:
# Orphan items
badges_html_amber = badges_html.replace("bg-stone-100", "bg-amber-100")
return (
f'<div class="rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5">'
f'<div class="flex items-start gap-4">'
f'<div class="h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0">'
f'<i class="fa fa-shopping-cart text-amber-500 text-xl" aria-hidden="true"></i></div>'
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900">Other items</h3>{badges_html_amber}</div>'
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">&pound;{total:.2f}</div></div></div></div>'
)
def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
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">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
)
cards = [_page_group_card_html(grp, ctx) for grp in page_groups]
has_items = any(c for c in cards)
if not has_items:
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">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
)
return '<div class="max-w-full px-3 py-3 space-y-3"><div class="space-y-4">' + "".join(cards) + '</div></div>'
# ---------------------------------------------------------------------------
# Page cart
# ---------------------------------------------------------------------------
def _cart_item_html(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 = f'<img src="{p.image}" alt="{p.title}" class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" loading="lazy">'
else:
img = '<div class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400">No image</div>'
price_html = ""
if unit_price:
price_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">{symbol}{unit_price:.2f}</p>'
if p.special_price and p.special_price != p.regular_price:
price_html += f'<p class="text-xs text-stone-400 line-through">{symbol}{p.regular_price:.2f}</p>'
else:
price_html = '<p class="text-xs text-stone-500">No price</p>'
deleted_html = ""
if getattr(item, "is_deleted", False):
deleted_html = (
'<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">'
'<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>'
' This item is no longer available or price has changed</p>'
)
brand_html = f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{p.brand}</p>' if getattr(p, "brand", None) else ""
line_total_html = ""
if unit_price:
lt = unit_price * item.quantity
line_total_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: {symbol}{lt:.2f}</p>'
return (
f'<article id="cart-item-{slug}" class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5">'
f'<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">{img}</div>'
f'<div class="flex-1 min-w-0">'
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
f'<div class="min-w-0"><h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">'
f'<a href="{prod_url}" class="hover:text-emerald-700">{p.title}</a></h2>{brand_html}{deleted_html}</div>'
f'<div class="text-left sm:text-right">{price_html}</div></div>'
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity - 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{item.quantity}</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity + 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
f'<div class="flex items-center justify-between sm:justify-end gap-3">{line_total_html}</div></div></div></article>'
)
def _calendar_entries_html(entries: list) -> str:
"""Render calendar booking entries in cart."""
if not entries:
return ""
items = []
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_html = f" \u2013 {end}" if end else ""
items.append(
f'<li class="flex items-start justify-between text-sm">'
f'<div><div class="font-medium">{name}</div>'
f'<div class="text-xs text-stone-500">{start}{end_html}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{cost:.2f}</div></li>'
)
return (
'<div class="mt-6 border-t border-stone-200 pt-4">'
'<h2 class="text-base font-semibold mb-2">Calendar bookings</h2>'
f'<ul class="space-y-2">{"".join(items)}</ul></div>'
)
def _ticket_groups_html(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 = ['<div class="mt-6 border-t border-stone-200 pt-4">',
'<h2 class="text-base font-semibold mb-2"><i class="fa fa-ticket mr-1" aria-hidden="true"></i> Event tickets</h2>',
'<div class="space-y-3">']
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_html = f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{tt_name}</p>' if tt_name else ""
tt_hidden = f'<input type="hidden" name="ticket_type_id" value="{tt_id}">' if tt_id else ""
parts.append(
f'<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">'
f'<div class="flex-1 min-w-0">'
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
f'<div class="min-w-0"><h3 class="text-sm sm:text-base font-semibold text-stone-900">{name}</h3>{tt_name_html}'
f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{date_str}</p></div>'
f'<div class="text-left sm:text-right"><p class="text-sm sm:text-base font-semibold text-stone-900">\u00a3{price or 0:.2f}</p></div></div>'
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
f'<input type="hidden" name="count" value="{max(quantity - 1, 0)}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{quantity}</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
f'<input type="hidden" name="count" value="{quantity + 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
f'<div class="flex items-center justify-between sm:justify-end gap-3">'
f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: \u00a3{line_total:.2f}</p></div></div></div></article>'
)
parts.append('</div></div>')
return "".join(parts)
def _cart_summary_html(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_html = (
f'<form method="post" action="{action}" class="w-full">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa-solid fa-credit-card mr-2" aria-hidden="true"></i> Checkout as {user.email}</button></form>'
)
else:
href = login_url(request.url)
checkout_html = (
f'<div class="w-full flex"><a href="{href}" class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition">'
f'<i class="fa-solid fa-key"></i><span>sign in or register to checkout</span></a></div>'
)
return (
f'<aside id="cart-summary" class="lg:pl-2"><div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">'
f'<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">Order summary</h2>'
f'<dl class="space-y-2 text-xs sm:text-sm">'
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Items</dt><dd class="text-stone-900">{item_count}</dd></div>'
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Subtotal</dt><dd class="text-stone-900">{symbol}{grand:.2f}</dd></div></dl>'
f'<div class="flex flex-col items-center w-full"><h1 class="text-5xl mt-2">This is a test - it will not take actual money</h1>'
f'<div>use dummy card number: 5555 5555 5555 4444</div></div>'
f'<div class="mt-4 sm:mt-5">{checkout_html}</div></div></aside>'
)
def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
tickets: list, ticket_groups: list,
total_fn: Any, cal_total_fn: Any,
ticket_total_fn: Any) -> str:
"""Page cart main panel."""
if not cart and not cal_entries and not tickets:
return (
'<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">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div></div>'
)
items_html = "".join(_cart_item_html(item, ctx) for item in cart)
cal_html = _calendar_entries_html(cal_entries)
tickets_html = _ticket_groups_html(ticket_groups, ctx)
summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
return (
f'<div class="max-w-full px-3 py-3 space-y-3"><div id="cart">'
f'<div><section class="space-y-3 sm:space-y-4">{items_html}{cal_html}{tickets_html}</section>'
f'{summary_html}</div></div></div>'
)
# ---------------------------------------------------------------------------
# Orders list (same pattern as orders service)
# ---------------------------------------------------------------------------
def _order_row_html(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"
)
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}"
return (
f'<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">'
f'<td class="px-3 py-2 align-top"><span class="font-mono text-[11px] sm:text-xs">#{order.id}</span></td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{created}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{order.description or ""}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{total}</td>'
f'<td class="px-3 py-2 align-top"><span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}">{status}</span></td>'
f'<td class="px-3 py-0.5 align-top text-right"><a href="{detail_url}" class="inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">View</a></td></tr>'
f'<tr class="sm:hidden border-t border-stone-100"><td colspan="5" class="px-3 py-3"><div class="flex flex-col gap-2 text-xs">'
f'<div class="flex items-center justify-between gap-2"><span class="font-mono text-[11px] text-stone-700">#{order.id}</span>'
f'<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}">{status}</span></div>'
f'<div class="text-[11px] text-stone-500 break-words">{created}</div>'
f'<div class="flex items-center justify-between gap-2"><div class="font-medium text-stone-800">{total}</div>'
f'<a href="{detail_url}" class="inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0">View</a></div></div></td></tr>'
)
def _orders_rows_html(orders: list, page: int, total_pages: int,
url_for_fn: Any, qs_fn: Any) -> str:
"""Render order rows + infinite scroll sentinel."""
from shared.utils import route_prefix
pfx = route_prefix()
parts = [
_order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
for o in orders
]
if page < total_pages:
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
parts.append(sexp(
'(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)',
u=next_url, p=page, **{"total-pages": total_pages},
))
else:
parts.append('<tr><td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">End of results</td></tr>')
return "".join(parts)
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
"""Main panel for orders list."""
if not orders:
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-4 sm:p-6 text-sm text-stone-700">'
'No orders yet.</div></div>'
)
return (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">'
'<table class="min-w-full text-xs sm:text-sm">'
'<thead class="bg-stone-50 border-b border-stone-200 text-stone-600"><tr>'
'<th class="px-3 py-2 text-left font-medium">Order</th>'
'<th class="px-3 py-2 text-left font-medium">Created</th>'
'<th class="px-3 py-2 text-left font-medium">Description</th>'
'<th class="px-3 py-2 text-left font-medium">Total</th>'
'<th class="px-3 py-2 text-left font-medium">Status</th>'
'<th class="px-3 py-2 text-left font-medium"></th>'
f'</tr></thead><tbody>{rows_html}</tbody></table></div></div>'
)
def _orders_summary_html(ctx: dict) -> str:
"""Filter section for orders list."""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Recent orders placed via the checkout.</p></div>'
f'<div class="md:hidden">{search_mobile_html(ctx)}</div>'
'</header>'
)
# ---------------------------------------------------------------------------
# Single order detail
# ---------------------------------------------------------------------------
def _order_items_html(order: Any) -> str:
"""Render order items list."""
if not order or not order.items:
return ""
items = []
for item in order.items:
prod_url = market_product_url(item.product_slug)
img = (
f'<img src="{item.product_image}" alt="{item.product_title or "Product image"}"'
f' class="w-full h-full object-contain object-center" loading="lazy" decoding="async">'
if item.product_image else
'<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">No image</div>'
)
items.append(
f'<li><a class="w-full py-2 flex gap-3" href="{prod_url}">'
f'<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">{img}</div>'
f'<div class="flex-1 flex justify-between gap-3">'
f'<div><p class="font-medium">{item.product_title or "Unknown product"}</p>'
f'<p class="text-[11px] text-stone-500">Product ID: {item.product_id}</p></div>'
f'<div class="text-right whitespace-nowrap"><p>Qty: {item.quantity}</p>'
f'<p>{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}</p>'
f'</div></div></a></li>'
)
return (
'<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">'
'<h2 class="text-sm sm:text-base font-semibold mb-3">Items</h2>'
f'<ul class="divide-y divide-stone-100 text-xs sm:text-sm">{"".join(items)}</ul></div>'
)
def _order_summary_html(order: Any) -> str:
"""Order summary card."""
return sexp(
'(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)',
oid=order.id,
ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
d=order.description, s=order.status, c=order.currency,
ta=f"{order.total_amount:.2f}" if order.total_amount else None,
)
def _order_calendar_items_html(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order."""
if not calendar_entries:
return ""
items = []
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"
)
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
items.append(
f'<li class="px-4 py-3 flex items-start justify-between text-sm">'
f'<div><div class="font-medium flex items-center gap-2">{e.name}'
f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}">'
f'{st.capitalize()}</span></div>'
f'<div class="text-xs text-stone-500">{ds}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{e.cost or 0:.2f}</div></li>'
)
return (
'<section class="mt-6 space-y-3">'
'<h2 class="text-base sm:text-lg font-semibold">Calendar bookings in this order</h2>'
f'<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">{"".join(items)}</ul></section>'
)
def _order_main_html(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_html(order)
return f'<div class="max-w-full px-3 py-3 space-y-4">{summary}{_order_items_html(order)}{_order_calendar_items_html(calendar_entries)}</div>'
def _order_filter_html(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 = (
f'<a href="{pay_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm '
f'rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>Open payment page</a>'
) if status != "paid" else ""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
f'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Placed {created} &middot; Status: {status}</p></div>'
'<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">'
f'<a href="{list_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-list mr-2" aria-hidden="true"></i>All orders</a>'
f'<form method="post" action="{recheck_url}" class="inline"><input type="hidden" name="csrf_token" value="{csrf_token}">'
f'<button type="submit" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>Re-check status</button></form>'
f'{pay}</div></header>'
)
# ---------------------------------------------------------------------------
# Public API: Cart overview
# ---------------------------------------------------------------------------
async def render_overview_page(ctx: dict, page_groups: list) -> str:
"""Full page: cart overview."""
main = _overview_main_panel_html(page_groups, ctx)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))',
c=_cart_header_html(ctx),
)
return full_page(ctx, header_rows_html=hdr, content_html=main)
async def render_overview_oob(ctx: dict, page_groups: list) -> str:
"""OOB response for cart overview."""
main = _overview_main_panel_html(page_groups, ctx)
oobs = (
_cart_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, content_html=main)
# ---------------------------------------------------------------------------
# Public API: Page cart
# ---------------------------------------------------------------------------
async def render_page_cart_page(ctx: dict, page_post: Any,
cart: list, cal_entries: list, tickets: list,
ticket_groups: list, total_fn: Any,
cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""Full page: page-specific cart."""
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
total_fn, cal_total_fn, ticket_total_fn)
hdr = root_header_html(ctx)
child = _cart_header_html(ctx)
page_hdr = _page_cart_header_html(ctx, page_post)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c)'
' (div :id "cart-header-child" :class "flex flex-col w-full items-center" (raw! p)))',
c=child, p=page_hdr,
)
return full_page(ctx, header_rows_html=hdr, content_html=main)
async def render_page_cart_oob(ctx: dict, page_post: Any,
cart: list, cal_entries: list, tickets: list,
ticket_groups: list, total_fn: Any,
cal_total_fn: Any, ticket_total_fn: Any) -> str:
"""OOB response for page cart."""
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
total_fn, cal_total_fn, ticket_total_fn)
oobs = (
sexp('(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! p))',
p=_page_cart_header_html(ctx, page_post))
+ _cart_header_html(ctx, oob=True)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, content_html=main)
# ---------------------------------------------------------------------------
# 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_html(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_html(orders, rows)
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! o)))',
a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url),
)
return full_page(ctx, header_rows_html=hdr,
filter_html=_orders_summary_html(ctx),
aside_html=search_desktop_html(ctx),
content_html=main)
async def render_orders_rows(ctx: dict, orders: list, page: int,
total_pages: int, url_for_fn: Any,
qs_fn: Any) -> str:
"""Pagination: just the table rows."""
return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
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_html(orders, page, total_pages, url_for_fn, qs_fn)
main = _orders_main_panel_html(orders, rows)
oobs = (
_auth_header_html(ctx, oob=True)
+ sexp(
'(div :id "auth-header-child" :hx-swap-oob "outerHTML"'
' :class "flex flex-col w-full items-center" (raw! o))',
o=_orders_header_html(ctx, list_url),
)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs,
filter_html=_orders_summary_html(ctx),
aside_html=search_desktop_html(ctx),
content_html=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_html(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
hdr = root_header_html(ctx)
order_row = sexp(
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp")',
lh=detail_url, ll=f"Order {order.id}",
)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)'
' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))',
a=_auth_header_html(ctx),
b=_orders_header_html(ctx, list_url),
c=order_row,
)
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=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_html(order, calendar_entries)
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
order_row_oob = sexp(
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp" :oob true)',
lh=detail_url, ll=f"Order {order.id}",
)
oobs = (
sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob)
+ root_header_html(ctx, oob=True)
)
return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main)
# ---------------------------------------------------------------------------
# Public API: Checkout error
# ---------------------------------------------------------------------------
def _checkout_error_filter_html() -> str:
return (
'<header class="mb-6 sm:mb-8">'
'<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">'
'Checkout error</h1>'
'<p class="text-xs sm:text-sm text-stone-600">'
'We tried to start your payment with SumUp but hit a problem.</p>'
'</header>'
)
def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_html = ""
if order:
order_html = (
f'<p class="text-xs text-rose-800/80">'
f'Order ID: <span class="font-mono">#{order.id}</span></p>'
)
back_url = cart_url("/")
return (
'<div class="max-w-full px-3 py-3 space-y-4">'
'<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">'
f'<p class="font-medium">Something went wrong.</p>'
f'<p>{err_msg}</p>'
f'{order_html}'
'</div>'
'<div>'
f'<a href="{back_url}"'
' class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">'
'<i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>'
'Back to cart</a>'
'</div>'
'</div>'
)
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
"""Full page: checkout error."""
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))',
c=_cart_header_html(ctx),
)
filt = _checkout_error_filter_html()
content = _checkout_error_content_html(error, order)
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content)

12
cart/sx/calendar.sx Normal file
View File

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

20
cart/sx/checkout.sx Normal file
View File

@@ -0,0 +1,20 @@
;; Cart checkout error components
(defcomp ~cart-checkout-error-filter ()
(header :class "mb-6 sm:mb-8"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")
(p :class "text-xs sm:text-sm text-stone-600"
"We tried to start your payment with SumUp but hit a problem.")))
(defcomp ~cart-checkout-error-order-id (&key order-id)
(p :class "text-xs text-rose-800/80"
"Order ID: " (span :class "font-mono" order-id)))
(defcomp ~cart-checkout-error-content (&key error-msg order back-url)
(div :class "max-w-full px-3 py-3 space-y-4"
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
(p :class "font-medium" "Something went wrong.")
(p error-msg)
order)
(div (a :href back-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart"))))

44
cart/sx/header.sx Normal file
View File

@@ -0,0 +1,44 @@
;; Cart header components
(defcomp ~cart-page-label-img (&key src)
(img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~cart-all-carts-link (&key href)
(a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
(defcomp ~cart-header-child (&key inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-header-child-nested (&key outer inner)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
outer
(div :id "cart-header-child" :class "flex flex-col w-full items-center"
inner)))
(defcomp ~cart-header-child-oob (&key inner)
(div :id "cart-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-auth-header-child (&key auth orders)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
orders)))
(defcomp ~cart-auth-header-child-oob (&key inner)
(div :id "auth-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))
(defcomp ~cart-order-header-child (&key auth orders order)
(div :id "root-header-child" :class "flex flex-col w-full items-center"
auth
(div :id "auth-header-child" :class "flex flex-col w-full items-center"
orders
(div :id "orders-header-child" :class "flex flex-col w-full items-center"
order))))
(defcomp ~cart-orders-header-child-oob (&key inner)
(div :id "orders-header-child" :sx-swap-oob "outerHTML" :class "flex flex-col w-full items-center"
inner))

66
cart/sx/items.sx Normal file
View File

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

53
cart/sx/order_detail.sx Normal file
View File

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

51
cart/sx/orders.sx Normal file
View File

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

52
cart/sx/overview.sx Normal file
View File

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

21
cart/sx/payments.sx Normal file
View File

@@ -0,0 +1,21 @@
;; Cart payments components
(defcomp ~cart-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
(section :class "p-4 max-w-lg mx-auto"
(div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :sx-put update-url :sx-target "#payments-panel" :sx-swap "outerHTML" :sx-select "#payments-panel" :class "space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder :class input-cls)
(when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" :class input-cls))
(button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
(when sumup-configured (span :class "ml-2 text-xs text-green-600"
(i :class "fa fa-check-circle") " Connected"))))))

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