72 Commits

Author SHA1 Message Date
6ca46bb295 Exclude reader-macro-demo.sx from component loader
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Rename to .sx.future — the file uses #z3 reader macros that aren't
implemented yet, causing a ParseError that blocks ALL component loading
and breaks the provide docs page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:28:26 +00:00
e1a5e3eb89 Reframe spreads article around provide/emit! as the mechanism
Lead with provide/emit! from the first sentence. make-spread/spread?/spread-attrs
are now presented as user-facing API on top of the provide/emit! substrate,
not as independent primitives. Restructured sections, removed redundant
"deeper primitive" content that duplicated the new section I.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:12:47 +00:00
aef990735f Add provide/emit! geography article, update spreads article, fix foundations rendering
- New geography article (provide.sx): four primitives, demos, nested scoping,
  adapter comparison, spec explorer links
- Updated spreads article section VI: provide/emit! is now implemented, not planned
- Fixed foundations.sx: ~docs/code-block → ~docs/code (undefined component
  was causing the page to silently fail to render)
- Added nav entry and defpage route for provide/emit! article

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:04:52 +00:00
04d3b2ecaf Use separate CI build directory to avoid clobbering dev working tree
CI was doing git reset --hard on /root/rose-ash (the dev directory),
flipping the checked-out branch and causing empty diffs when merging.
Now builds in /root/rose-ash-ci and uses push event SHAs for diffing.
Also adds --resolve-image always to stack deploys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:42:00 +00:00
c4a999d0d0 Merge branch 'worktree-api-urls' into macros 2026-03-13 15:41:40 +00:00
2de4ba8c57 Refactor spread to use provide/emit! internally
Spreads now emit their attrs into the nearest element's provide scope
instead of requiring per-child spread? checks at every intermediate
layer. emit! is tolerant (no-op when no provider), so spreads in
non-element contexts silently vanish.

- adapter-html: element/lake/marsh wrap children in provide, collect
  emitted; removed 14 spread filters from fragment, forms, components
- adapter-sx: aser wraps result to catch spread values from fn calls;
  aser-call uses provide with attr-parts/child-parts ordering
- adapter-async: same pattern for both render and aser paths
- adapter-dom: added emit! in spread dispatch + provide in element
  rendering; kept spread? checks for reactive/island and DOM safety
- platform: emit! returns NIL when no provider instead of erroring
- 3 new aser tests: stored spread, nested element, silent drop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:41:32 +00:00
ee969a343c Merge branch 'macros'
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m58s
2026-03-13 12:41:09 +00:00
400d6d4086 Merge branch 'worktree-api-urls' into macros 2026-03-13 12:20:27 +00:00
dbf16929fa Merge branch 'worktree-api-urls'
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-03-13 12:20:22 +00:00
859aad4333 Fix spread serialization in aser/async-aser wire format
Spread values from make-spread were crashing the wire format serializer:
- serialize() had no "spread" case, fell through to (str val) producing
  Python repr "<shared.sx.ref.sx_ref._Spread...>" which was treated as
  an undefined symbol
- aser-call/async-aser-call didn't handle spread children — now merges
  spread attrs as keyword args into the parent element
- aser-fragment/async-aser-fragment didn't filter spreads — now filters
  them (fragments have no parent element to merge into)
- serialize() now handles spread type: (make-spread {:key "val"})

Added 3 aser-spreads tests. All 562 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:20:16 +00:00
c95e320825 Merge branch 'worktree-api-urls' into macros
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-03-13 12:07:05 +00:00
427dee13f0 Add scoped-effects + foundations to defpage plan-page dispatch
The plans were routed in page-functions.sx (GraphSX URL eval) but
missing from the defpage case in docs.sx (server-side slug route).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:06:56 +00:00
a7de0e9410 Merge branch 'worktree-api-urls' into macros 2026-03-13 12:04:30 +00:00
214963ea6a Unicode escapes, variadic infix fix, spreads demos, scoped-effects + foundations plans
- Add \uXXXX unicode escape support to parser.py and parser.sx spec
- Add char-from-code primitive (Python chr(), JS String.fromCharCode())
- Fix variadic infix operators in both bootstrappers (js.sx, py.sx) —
  (+ a b c d) was silently dropping terms, now left-folds correctly
- Rebootstrap sx_ref.py and sx-browser.js with all fixes
- Fix 3 pre-existing map-dict test failures in shared/sx/tests/run.py
- Add live demos alongside examples in spreads essay (side-by-side layout)
- Add scoped-effects plan: algebraic effects as unified foundation for
  spread/collect/island/lake/signal/context
- Add foundations plan: CEK machine, the computational floor, three-axis
  model (depth/topology/linearity), Curry-Howard correspondence
- Route both plans in page-functions.sx and nav-data.sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:03:58 +00:00
2fc391696c Merge branch 'worktree-api-urls' into macros 2026-03-13 10:46:53 +00:00
28a6560963 Replace \uXXXX escapes with actual UTF-8 characters in .sx files
SX parser doesn't process \u escapes — they render as literal text.
Use actual UTF-8 characters (→, —, £, ⬡) directly in source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:46:53 +00:00
cee0ca7667 Merge branch 'worktree-api-urls' into macros 2026-03-13 10:44:10 +00:00
98036b2292 Add syntax highlighting to spreads page code blocks
Use (highlight "..." "lisp") page helper instead of raw strings
for ~docs/code :code values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:44:09 +00:00
6d0c0b2230 Merge branch 'worktree-api-urls' into macros 2026-03-13 05:42:51 +00:00
9d0bd3b0e7 Fix spreads page: remove (code) tags from table list data
(code "...") is an HTML tag — works in render context but not inside
(list ...) which fully evaluates args. Use plain strings in table rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:42:47 +00:00
2329533d1a Merge branch 'worktree-api-urls' into macros 2026-03-13 05:35:56 +00:00
085f959323 Add spreads page function for SX URL routing
Without this, /sx/(geography.(spreads)) 404s because spreads isn't
defined as a page function to return the content component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:35:50 +00:00
fe911625e3 Merge branch 'worktree-api-urls' into macros 2026-03-13 05:31:40 +00:00
9806aec60c Add Spreads page under Geography — spread/collect/reactive-spread docs
Documents the three orthogonal primitives (spread, collect!, reactive-spread),
their operation across server/client/morph boundaries, CSSX as use case,
semantic style variables, and the planned provide/context/emit! unification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:25:42 +00:00
36b070f796 Add reactive spreads — signal-driven attribute injection in islands
When a spread value (e.g. from ~cssx/tw) appears inside an island with
signal-dependent tokens, reactive-spread tracks deps and updates the
element's class/attrs when signals change. Old classes are surgically
removed, new ones appended, and freshly collected CSS rules are flushed
to the live stylesheet. Multiple reactive spreads on one element are safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:16:13 +00:00
ae6c6d06a7 Merge branch 'worktree-api-urls' into macros 2026-03-13 04:51:05 +00:00
846719908f Reactive forms pass spreads through instead of wrapping in fragments
adapter-dom.sx: if/when/cond reactive paths now check whether
initial-result is a spread. If so, return it directly — spreads
aren't DOM nodes and can't be appended to fragments. This lets
any spread-returning component (like ~cssx/tw) work inside islands
without the spread being silently dropped.

cssx.sx: revert make-spread workaround — the root cause is now
fixed in the adapter. ~cssx/tw can use a natural top-level if.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:51:05 +00:00
301bb8e585 Merge branch 'worktree-api-urls' into macros 2026-03-13 04:44:59 +00:00
d42972518a Revert ~cssx/tw to keyword calling — positional breaks param binding
Component params are bound from kwargs only in render-dom-component.
Positional args go to children, so (~ cssx/tw "...") binds tokens=nil.
The :tokens keyword is required: (~cssx/tw :tokens "...").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:44:59 +00:00
071869331f Merge branch 'worktree-api-urls' into macros 2026-03-13 04:41:14 +00:00
2fd64351d0 Fix ~cssx/tw positional calling + move flush after content
layouts.sx: change all (~ cssx/tw :tokens "...") to (~cssx/tw "...")
matching the documented positional calling convention.

Move (~cssx/flush) after children so page content rules are also
collected before the server-side <style data-cssx> flush.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:41:14 +00:00
9096476402 Merge branch 'worktree-api-urls' into macros 2026-03-13 04:39:06 +00:00
0847824935 Remove debug logging from sx-browser.js
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:39:06 +00:00
b31eb393c4 Merge branch 'worktree-api-urls' into macros 2026-03-13 04:37:53 +00:00
2c97542ee8 Fix island dep scanning + spread-through-reactive-if debug
deps.sx: scan island bodies for component deps (was only scanning
"component" and "macro", missing "island" type). This ensures
~cssx/tw and its dependencies are sent to the client for islands.

cssx.sx: move if inside make-spread arg so it's evaluated by
eval-expr (no reactive wrapping) instead of render-to-dom which
applies reactive-if inside island scope, converting the spread
into a fragment and losing the class attrs.

Added island dep tests at 3 levels: test-deps.sx (spec),
test_deps.py (Python), test_parity.py (ref vs fallback).

sx-browser.js: temporary debug logging at spread detection points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:37:45 +00:00
04539675d8 Merge branch 'worktree-api-urls' into macros 2026-03-13 04:09:32 +00:00
1d1e7f30bb Add flush-cssx-to-dom: client-side CSSX rule injection
Islands render independently on the client, so ~cssx/tw calls
collect!("cssx", rule) but no ~cssx/flush runs. Add flush-cssx-to-dom
in boot.sx that injects collected rules into a persistent <style>
element in <head>.

Called at all lifecycle points: boot-init, sx-mount, resolve-suspense,
post-swap (navigation morph), and swap-rendered-content (client routes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:09:23 +00:00
56dfff8299 Merge branch 'worktree-api-urls' into macros 2026-03-13 03:41:10 +00:00
f52b9e880b Guard all appendChild calls against spread values
The previous fix only guarded domAppend/domInsertAfter, but many
platform JS functions (asyncRenderChildren, asyncRenderElement,
asyncRenderMap, render, sxRenderWithEnv) call appendChild directly.

Add _spread guards to all direct appendChild sites. For async element
rendering, merge spread attrs onto parent (class/style join, others
overwrite) matching the sync adapter behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 03:41:07 +00:00
a0d78e44d5 Merge branch 'worktree-api-urls' into macros 2026-03-13 03:35:15 +00:00
9284a946ba Guard domAppend/domInsertAfter against spread values
Spread values (from ~cssx/tw etc.) are attribute dicts, not DOM nodes.
When they appear in non-element contexts (fragments, islands, lakes,
reactive branches), they must not be passed to appendChild/insertBefore.

Add _spread guard to platform domAppend and domInsertAfter — fixes
TypeError: Node.appendChild: Argument 1 does not implement interface Node.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 03:35:00 +00:00
11ea641f7b Merge branch 'worktree-api-urls' into macros 2026-03-13 03:23:22 +00:00
c3430ade90 Fix DOM adapter: filter spread values from dom-append calls
Spread values returned by components like ~cssx/tw are not DOM nodes
and cannot be passed to appendChild. Filter them in fragment, let,
begin/do, component children, and data list rendering paths — matching
the HTML adapter's existing spread filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 03:23:17 +00:00
1f22f3fcd5 Merge branch 'worktree-api-urls' into macros 2026-03-13 03:18:03 +00:00
8100dc5fc9 Convert ~layouts/header from inline tw() to ~cssx/tw spreads
Class-based styling with JIT CSS rules collected into a single
<style> tag via ~cssx/flush in ~layouts/doc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 03:17:53 +00:00
5f6600f572 Merge branch 'worktree-api-urls' into macros 2026-03-13 02:58:39 +00:00
ea2b71cfa3 Add provide/context/emit!/emitted — render-time dynamic scope
Four new primitives for scoped downward value passing and upward
accumulation through the render tree. Specced in .sx, bootstrapped
to Python and JS across all adapters (eval, html, sx, dom, async).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:58:21 +00:00
41097eeef9 Add spread + collect primitives, rewrite ~cssx/tw as defcomp
New SX primitives for child-to-parent communication in the render tree:
- spread type: make-spread, spread?, spread-attrs — child injects attrs
  onto parent element (class joins with space, style with semicolon)
- collect!/collected/clear-collected! — render-time accumulation with
  dedup into named buckets

~cssx/tw is now a proper defcomp returning a spread value instead of a
macro wrapping children. ~cssx/flush reads collected "cssx" rules and
emits a single <style data-cssx> tag.

All four render adapters (html, async, dom, aser) handle spread values.
Both bootstraps (Python + JS) regenerated. Also fixes length→len in
cssx.sx (length was never a registered primitive).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:38:31 +00:00
c2efa192c5 Rewrite CSSX: unified Tailwind-style utility token system
Replace the three-layer cssx system (macro + value functions + class
components) with a single token resolver. Tokens like "bg-yellow-199",
"hover:bg-rose-500", "md:text-xl" are parsed into CSS declarations.

Two delivery mechanisms, same token format:
- tw() function: returns inline style string for :style
- ~cssx/tw macro: injects JIT class + <style> onto first child element

The resolver handles: colours (21 names, any shade 0-950), spacing,
typography, display, max-width, rounded, opacity, w/h, gap, text
decoration, cursor, overflow, transitions. States (hover/focus/active)
and responsive breakpoints (sm/md/lg/xl/2xl) for class-based usage.

Next step: replace macro/function approach with spec-level primitives
(defcontext/provide/context + spread) so ~cssx/tw becomes a proper
component returning spread values, with rules collected via context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:37:35 +00:00
100450772f Cache parsed components for 10x faster startup (2s → 200ms)
- Fix O(n²) postprocessing: compute_all_deps/io_refs/hash were called
  per-file (92x for sx app). Now deferred to single finalize_components()
  call after all files load.
- Add pickle cache in shared/sx/.cache/ keyed by file mtimes+sizes.
  Cache stores fully-processed Component/Island/Macro objects with deps,
  io_refs, and css_classes pre-computed. Closures stripped before pickle,
  rebuilt from global env after restore.
- Smart finalization: cached loads skip deps/io_refs recomputation
  (already in pickle), only recompute component hash.
- Fix ~sx-header → ~layouts/header ref in docs-content.sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:54:38 +00:00
7c969f9192 Remove redundant 'click to navigate' prompts from SX URLs page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:30:59 +00:00
bc1ea0128f Merge worktree-api-urls: remove click prompts 2026-03-12 23:30:59 +00:00
0358b6ec9e Merge worktree-api-urls: rewrite SX URLs documentation page 2026-03-12 23:25:12 +00:00
a2d8fb0f0f Rewrite SX URLs documentation page
- All example URLs are now clickable live links
- New section: "Routing Is Functional Application" — section functions,
  page functions, data-dependent pages with real code from page-functions.sx
- New section: "Server-Side: URL → eval → Response" — the Python handler,
  auto-quoting spec, defhandler endpoints with live API links
- New section: "Client-Side: eval in the Browser" — try-client-route,
  prepare-url-expr bootstrapped to JS
- Expanded "Relative URLs as Function Application" — structural transforms
  vs string manipulation, keyword arguments, delta values, resolve spec
- Expanded special forms with parse-sx-url spec code and sigil table
- Every page on the site listed as clickable link in hierarchy section
- Live defhandler endpoints (ref-time, swap-item, click) linked directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:25:06 +00:00
cedff42d15 Rewrite essay around self-definition as the hypermedium criterion
JSON can't define itself. HTML can carry its spec but not execute it.
SX's spec IS the language — eval.sx is the evaluator, not documentation
about the evaluator. Progressive discovery, components, evaluable URLs,
and AI legibility all flow as consequences of self-definition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:17:47 +00:00
1324e984ef Merge worktree-api-urls: spec URL evaluation in router.sx 2026-03-12 23:05:05 +00:00
5f06e2e2cc Spec URL evaluation in router.sx, bootstrap to Python/JS
Add url-to-expr, auto-quote-unknowns, prepare-url-expr to router.sx —
the canonical URL-to-expression pipeline. Dots→spaces, parse, then
auto-quote unknown symbols as strings (slugs). The same spec serves
both server (Python) and client (JS) route handling.

- router.sx: three new pure functions for URL evaluation
- bootstrap_py.py: auto-include router module with html adapter
- platform_js.py: export urlToExpr/autoQuoteUnknowns/prepareUrlExpr
- sx_router.py: replace hand-written auto_quote_slugs with bootstrapped
  prepare_url_expr — delete ~50 lines of hardcoded function name sets
- Rebootstrap sx_ref.py (4331 lines) and sx-browser.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:05:01 +00:00
b9d85bd797 Fix essay component names to match path-based convention
~doc-page → ~docs/page, ~doc-section → ~docs/section,
~doc-code → ~docs/code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:38:26 +00:00
1dd2d73766 Merge worktree-api-urls: fix dep scanner regex for component paths
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:27:59 +00:00
355f57a60b Fix component name regex to support : and / in paths
The dep scanner regex only matched [a-zA-Z0-9_-] in component names,
missing the new path separators (/) and namespace delimiters (:).
Fixed in deps.sx spec + rebootstrapped sx_ref.py and sx-browser.js.
Also fixed the Python fallback in deps.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:27:52 +00:00
c6a4a6f65c Merge worktree-api-urls: fix Python string-form component name refs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:14:08 +00:00
6186cd1c53 Fix Python string-form component name references
The rename script only matched ~prefixed names in .sx files.
Python render calls use bare strings like render_to_html("name")
which also need updating: 37 replacements across 8 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:13:47 +00:00
1647921895 Add essay: Hypermedia in the Age of AI
Response to Nick Blow's article on JSON hypermedia and LLM agents.
Argues SX resolves the HTML-vs-JSON debate by being simultaneously
content, control, and code in one homoiconic format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:02:33 +00:00
b0920a1121 Rename all 1,169 components to path-based names with namespace support
Component names now reflect filesystem location using / as path separator
and : as namespace separator for shared components:
  ~sx-header → ~layouts/header
  ~layout-app-body → ~shared:layout/app-body
  ~blog-admin-dashboard → ~admin/dashboard

209 files, 4,941 replacements across all services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:00:12 +00:00
de80d921e9 Prefix all SX URLs with /sx/ for WhatsApp-safe sharing
All routes moved under /sx/ prefix:
- / redirects to /sx/
- /sx/ serves home page
- /sx/<path:expr> is the catch-all for SX expression URLs
- Bare /(...) and /~... redirect to /sx/(...) and /sx/~...
- All ~600 hrefs, sx-get attrs, defhandler paths, redirect
  targets, and blueprint routes updated across 44 files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:07:09 +00:00
acd2fa6541 Add SX URLs documentation page, fix layout strapline
New comprehensive documentation for SX URLs at /(applications.(sx-urls))
covering dots-as-spaces, nesting/scoping, relative URLs, keyword ops,
delta values, special forms, hypermedia integration, and GraphSX.
Fix layout tagline: "A" → "The" framework-free reactive hypermedium.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:54:33 +00:00
b23e81730c SX URL algebra: relative resolution, keyword ops, ! special forms
Extends router.sx with the full SX URL algebra — structural navigation
(.slug, .., ...), keyword set/delta (.:page.4, .:page.+1), bare-dot
shorthand, and ! special form parsing (!source, !inspect, !diff, !search,
!raw, !json). All pure SX spec, bootstrapped to both Python and JS.

Fixes: index-of -1/nil portability (_index-of-safe wrapper), variadic
(+ a b c) transpilation bug (use nested binary +). Includes 115 passing
tests covering all operations. Also: "The" strapline and essay title.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:31:21 +00:00
7a1d1e9ea2 Phase 5: Update all content paths to SX expression URLs
- Update ~sx-doc :path values in docs.sx from old-style paths to SX
  expression URLs (fixes client-side rendered page nav resolution)
- Fix stale hrefs in content/pages.py code examples
- Fix tabs push-url in examples.sx
- Add self-defining-medium + sx-urls + sx-protocol to essay/plan cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:39:13 +00:00
9f2f4377b9 Add essay: A True Hypermedium Must Define Itself With Itself
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 8m27s
On ontological uniformity, the metacircular web, and why address
and content should be made of the same stuff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:56:18 +00:00
f759cd6688 Fix stale href in specs-explorer.sx
Convert /language/specs/<slug> to SX expression URL format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:28:42 +00:00
2076e1805f Phase 4: Client-side routing for SX expression URLs
Add sx-url-to-path to router.sx that converts SX expression URLs to
old-style slash paths for route matching. find-matching-route now
transparently handles both formats — the browser URL stays as the SX
expression while matching uses the equivalent old-style path.

/(language.(doc.introduction)) → /language/docs/introduction for matching
but pushState keeps the SX URL in the browser bar.

- router.sx: add _fn-to-segment (doc→docs, etc.), sx-url-to-path
- router.sx: modify find-matching-route to convert SX URLs before matching
- Rebootstrap sx-browser.js and sx_ref.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:27:28 +00:00
feecbb66ba Convert all API endpoint URLs to SX expression format
Every URL at sx-web.org now uses bracketed SX expressions — pages AND
API endpoints. defhandler :path values, sx-get/sx-post/sx-delete attrs,
code examples, and Python route decorators all converted.

- Add SxAtomConverter to handlers.py for parameter matching inside
  expression URLs (e.g. /(api.(item.<sx:item_id>)))
- Convert ~50 defhandler :path values in ref-api.sx and examples.sx
- Convert ~90 sx-get/sx-post/sx-delete URLs in reference.sx, examples.sx
- Convert ~30 code example URLs in examples-content.sx
- Convert ~30 API URLs in pages.py (Python string code examples)
- Convert ~70 page navigation URLs in pages.py
- Convert 7 Python route decorators in routes.py
- Convert ~10 reactive API URLs in marshes.sx
- Add API redirect patterns to sx_router.py (301 for old paths)
- Remove /api/ skip in app.py redirects (old API paths now redirect)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:02:26 +00:00
252 changed files with 12082 additions and 5995 deletions

View File

@@ -7,6 +7,7 @@ on:
env: env:
REGISTRY: registry.rose-ash.com:5000 REGISTRY: registry.rose-ash.com:5000
APP_DIR: /root/rose-ash APP_DIR: /root/rose-ash
BUILD_DIR: /root/rose-ash-ci
jobs: jobs:
build-and-deploy: build-and-deploy:
@@ -33,23 +34,26 @@ jobs:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: | run: |
ssh "root@$DEPLOY_HOST" " ssh "root@$DEPLOY_HOST" "
cd ${{ env.APP_DIR }} # --- Build in isolated CI directory (never touch dev working tree) ---
BUILD=${{ env.BUILD_DIR }}
# Save current HEAD before updating ORIGIN=\$(git -C ${{ env.APP_DIR }} remote get-url origin)
OLD_HEAD=\$(git rev-parse HEAD 2>/dev/null || echo none) if [ ! -d \"\$BUILD/.git\" ]; then
git clone \"\$ORIGIN\" \"\$BUILD\"
git fetch origin ${{ github.ref_name }} fi
cd \"\$BUILD\"
git fetch origin
git reset --hard origin/${{ github.ref_name }} git reset --hard origin/${{ github.ref_name }}
NEW_HEAD=\$(git rev-parse HEAD) # Detect changes using push event SHAs (not local checkout state)
BEFORE='${{ github.event.before }}'
AFTER='${{ github.sha }}'
# Detect what changed
REBUILD_ALL=false REBUILD_ALL=false
if [ \"\$OLD_HEAD\" = \"none\" ] || [ \"\$OLD_HEAD\" = \"\$NEW_HEAD\" ]; then if [ -z \"\$BEFORE\" ] || [ \"\$BEFORE\" = '0000000000000000000000000000000000000000' ] || ! git cat-file -e \"\$BEFORE\" 2>/dev/null; then
# First deploy or CI re-run on same commit — rebuild all # New branch, force push, or unreachable parent — rebuild all
REBUILD_ALL=true REBUILD_ALL=true
else else
CHANGED=\$(git diff --name-only \$OLD_HEAD \$NEW_HEAD) CHANGED=\$(git diff --name-only \$BEFORE \$AFTER)
if echo \"\$CHANGED\" | grep -q '^shared/'; then if echo \"\$CHANGED\" | grep -q '^shared/'; then
REBUILD_ALL=true REBUILD_ALL=true
fi fi
@@ -86,8 +90,8 @@ jobs:
# Deploy swarm stacks only on main branch # Deploy swarm stacks only on main branch
if [ '${{ github.ref_name }}' = 'main' ]; then if [ '${{ github.ref_name }}' = 'main' ]; then
source .env source ${{ env.APP_DIR }}/.env
docker stack deploy -c docker-compose.yml rose-ash docker stack deploy --resolve-image always -c docker-compose.yml rose-ash
echo 'Waiting for swarm services to update...' echo 'Waiting for swarm services to update...'
sleep 10 sleep 10
docker stack services rose-ash docker stack services rose-ash
@@ -99,17 +103,17 @@ jobs:
fi fi
if [ \"\$SX_REBUILT\" = true ]; then if [ \"\$SX_REBUILT\" = true ]; then
echo 'Deploying sx-web stack (sx-web.org)...' echo 'Deploying sx-web stack (sx-web.org)...'
docker stack deploy -c /root/sx-web/docker-compose.yml sx-web docker stack deploy --resolve-image always -c /root/sx-web/docker-compose.yml sx-web
sleep 5 sleep 5
docker stack services sx-web docker stack services sx-web
# Reload Caddy to pick up any Caddyfile changes
docker service update --force caddy_caddy 2>/dev/null || true docker service update --force caddy_caddy 2>/dev/null || true
fi fi
else else
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})' echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
fi fi
# Dev stack always deployed (bind-mounted source + auto-reload) # Dev stack uses working tree (bind-mounted source + auto-reload)
cd ${{ env.APP_DIR }}
echo 'Deploying dev stack...' echo 'Deploying dev stack...'
docker compose -p rose-ash-dev -f docker-compose.yml -f docker-compose.dev.yml up -d docker compose -p rose-ash-dev -f docker-compose.yml -f docker-compose.dev.yml up -d
echo 'Dev stack deployed' echo 'Dev stack deployed'

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
shared/sx/.cache/
.env .env
node_modules/ node_modules/
*.egg-info/ *.egg-info/

View File

@@ -1,12 +1,12 @@
;; Auth page components (device auth — account-specific) ;; Auth page components (device auth — account-specific)
;; Login and check-email components are shared: see shared/sx/templates/auth.sx ;; Login and check-email components are shared: see shared/sx/templates/auth.sx
(defcomp ~account-device-error (&key (error :as string)) (defcomp ~auth/device-error (&key (error :as string))
(when error (when error
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
error))) error)))
(defcomp ~account-device-form (&key error (action :as string) (csrf-token :as string) (code :as string)) (defcomp ~auth/device-form (&key error (action :as string) (csrf-token :as string) (code :as string))
(div :class "py-8 max-w-md mx-auto" (div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-6" "Authorize device") (h1 :class "text-2xl font-bold mb-6" "Authorize device")
(p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.") (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")
@@ -22,30 +22,30 @@
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition" :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
"Authorize")))) "Authorize"))))
(defcomp ~account-device-approved () (defcomp ~auth/device-approved ()
(div :class "py-8 max-w-md mx-auto text-center" (div :class "py-8 max-w-md mx-auto text-center"
(h1 :class "text-2xl font-bold mb-4" "Device authorized") (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."))) (p :class "text-stone-600" "You can close this window and return to your terminal.")))
;; Assembled auth page content — replaces Python _login_page_content etc. ;; Assembled auth page content — replaces Python _login_page_content etc.
(defcomp ~account-login-content (&key (error :as string?) (email :as string?)) (defcomp ~auth/login-content (&key (error :as string?) (email :as string?))
(~auth-login-form (~shared:auth/login-form
:error (when error (~auth-error-banner :error error)) :error (when error (~shared:auth/error-banner :error error))
:action (url-for "auth.start_login") :action (url-for "auth.start_login")
:csrf-token (csrf-token) :csrf-token (csrf-token)
:email (or email ""))) :email (or email "")))
(defcomp ~account-device-content (&key (error :as string?) (code :as string?)) (defcomp ~auth/device-content (&key (error :as string?) (code :as string?))
(~account-device-form (~auth/device-form
:error (when error (~account-device-error :error error)) :error (when error (~auth/device-error :error error))
:action (url-for "auth.device_submit") :action (url-for "auth.device_submit")
:csrf-token (csrf-token) :csrf-token (csrf-token)
:code (or code ""))) :code (or code "")))
(defcomp ~account-check-email-content (&key (email :as string?) (email-error :as string?)) (defcomp ~auth/check-email-content (&key (email :as string?) (email-error :as string?))
(~auth-check-email (~shared:auth/check-email
:email (escape (or email "")) :email (escape (or email ""))
:error (when email-error :error (when email-error
(~auth-check-email-error :error (escape email-error))))) (~shared:auth/check-email-error :error (escape email-error)))))

View File

@@ -1,36 +1,36 @@
;; Account dashboard components ;; Account dashboard components
(defcomp ~account-error-banner (&key (error :as string)) (defcomp ~dashboard/error-banner (&key (error :as string))
(when error (when error
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" (div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
error))) error)))
(defcomp ~account-user-email (&key (email :as string)) (defcomp ~dashboard/user-email (&key (email :as string))
(when email (when email
(p :class "text-sm text-stone-500 mt-1" email))) (p :class "text-sm text-stone-500 mt-1" email)))
(defcomp ~account-user-name (&key (name :as string)) (defcomp ~dashboard/user-name (&key (name :as string))
(when name (when name
(p :class "text-sm text-stone-600" name))) (p :class "text-sm text-stone-600" name)))
(defcomp ~account-logout-form (&key (csrf-token :as string)) (defcomp ~dashboard/logout-form (&key (csrf-token :as string))
(form :action "/auth/logout/" :method "post" (form :action "/auth/logout/" :method "post"
(input :type "hidden" :name "csrf_token" :value csrf-token) (input :type "hidden" :name "csrf_token" :value csrf-token)
(button :type "submit" (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" :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"))) (i :class "fa-solid fa-right-from-bracket text-xs") " Sign out")))
(defcomp ~account-label-item (&key (name :as string)) (defcomp ~dashboard/label-item (&key (name :as string))
(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60" (span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60"
name)) name))
(defcomp ~account-labels-section (&key items) (defcomp ~dashboard/labels-section (&key items)
(when items (when items
(div (div
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels") (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
(div :class "flex flex-wrap gap-2" items)))) (div :class "flex flex-wrap gap-2" items))))
(defcomp ~account-main-panel (&key error email name logout labels) (defcomp ~dashboard/main-panel (&key error email name logout labels)
(div :class "w-full max-w-3xl mx-auto px-4 py-6" (div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8" (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"
error error
@@ -43,18 +43,18 @@
labels))) labels)))
;; Assembled dashboard content — replaces Python _account_main_panel_sx ;; Assembled dashboard content — replaces Python _account_main_panel_sx
(defcomp ~account-dashboard-content (&key (error :as string?)) (defcomp ~dashboard/content (&key (error :as string?))
(let* ((user (current-user)) (let* ((user (current-user))
(csrf (csrf-token))) (csrf (csrf-token)))
(~account-main-panel (~dashboard/main-panel
:error (when error (~account-error-banner :error error)) :error (when error (~dashboard/error-banner :error error))
:email (when (get user "email") :email (when (get user "email")
(~account-user-email :email (get user "email"))) (~dashboard/user-email :email (get user "email")))
:name (when (get user "name") :name (when (get user "name")
(~account-user-name :name (get user "name"))) (~dashboard/user-name :name (get user "name")))
:logout (~account-logout-form :csrf-token csrf) :logout (~dashboard/logout-form :csrf-token csrf)
:labels (when (not (empty? (or (get user "labels") (list)))) :labels (when (not (empty? (or (get user "labels") (list))))
(~account-labels-section (~dashboard/labels-section
:items (map (lambda (label) :items (map (lambda (label)
(~account-label-item :name (get label "name"))) (~dashboard/label-item :name (get label "name")))
(get user "labels"))))))) (get user "labels")))))))

View File

@@ -2,19 +2,19 @@
;; Registered via register_sx_layout("account", ...) in __init__.py. ;; Registered via register_sx_layout("account", ...) in __init__.py.
;; Full page: root header + auth header row in header-child ;; Full page: root header + auth header row in header-child
(defcomp ~account-layout-full () (defcomp ~layouts/full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (~auth-header-row-auto)))) :inner (~auth-header-row-auto))))
;; OOB (HTMX): auth row + root header, both with oob=true ;; OOB (HTMX): auth row + root header, both with oob=true
(defcomp ~account-layout-oob () (defcomp ~layouts/oob ()
(<> (~auth-header-row-auto true) (<> (~auth-header-row-auto true)
(~root-header-auto true))) (~root-header-auto true)))
;; Mobile menu: auth section + root nav ;; Mobile menu: auth section + root nav
(defcomp ~account-layout-mobile () (defcomp ~layouts/mobile ()
(<> (~mobile-menu-section (<> (~shared:layout/mobile-menu-section
:label "account" :href "/" :level 1 :colour "sky" :label "account" :href "/" :level 1 :colour "sky"
:items (~auth-nav-items-auto)) :items (~auth-nav-items-auto))
(~root-mobile-auto))) (~root-mobile-auto)))

View File

@@ -1,30 +1,30 @@
;; Newsletter management components ;; Newsletter management components
(defcomp ~account-newsletter-desc (&key (description :as string)) (defcomp ~newsletters/desc (&key (description :as string))
(when description (when description
(p :class "text-xs text-stone-500 mt-0.5 truncate" description))) (p :class "text-xs text-stone-500 mt-0.5 truncate" description)))
(defcomp ~account-newsletter-toggle (&key (id :as string) (url :as string) (hdrs :as dict) (target :as string) (cls :as string) (checked :as string) (knob-cls :as string)) (defcomp ~newsletters/toggle (&key (id :as string) (url :as string) (hdrs :as dict) (target :as string) (cls :as string) (checked :as string) (knob-cls :as string))
(div :id id :class "flex items-center" (div :id id :class "flex items-center"
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML" (button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
:class cls :role "switch" :aria-checked checked :class cls :role "switch" :aria-checked checked
(span :class knob-cls)))) (span :class knob-cls))))
(defcomp ~account-newsletter-item (&key (name :as string) desc toggle) (defcomp ~newsletters/item (&key (name :as string) desc toggle)
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0" (div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"
(div :class "min-w-0 flex-1" (div :class "min-w-0 flex-1"
(p :class "text-sm font-medium text-stone-800" name) (p :class "text-sm font-medium text-stone-800" name)
desc) desc)
(div :class "ml-4 flex-shrink-0" toggle))) (div :class "ml-4 flex-shrink-0" toggle)))
(defcomp ~account-newsletter-list (&key items) (defcomp ~newsletters/list (&key items)
(div :class "divide-y divide-stone-100" items)) (div :class "divide-y divide-stone-100" items))
(defcomp ~account-newsletter-empty () (defcomp ~newsletters/empty ()
(p :class "text-sm text-stone-500" "No newsletters available.")) (p :class "text-sm text-stone-500" "No newsletters available."))
(defcomp ~account-newsletters-panel (&key list) (defcomp ~newsletters/panel (&key list)
(div :class "w-full max-w-3xl mx-auto px-4 py-6" (div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6" (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Newsletters") (h1 :class "text-xl font-semibold tracking-tight" "Newsletters")
@@ -32,12 +32,12 @@
;; Assembled newsletters content — replaces Python _newsletters_panel_sx ;; Assembled newsletters content — replaces Python _newsletters_panel_sx
;; Takes pre-fetched newsletter-list from page helper ;; Takes pre-fetched newsletter-list from page helper
(defcomp ~account-newsletters-content (&key (newsletter-list :as list) (account-url :as string?)) (defcomp ~newsletters/content (&key (newsletter-list :as list) (account-url :as string?))
(let* ((csrf (csrf-token))) (let* ((csrf (csrf-token)))
(if (empty? newsletter-list) (if (empty? newsletter-list)
(~account-newsletter-empty) (~newsletters/empty)
(~account-newsletters-panel (~newsletters/panel
:list (~account-newsletter-list :list (~newsletters/list
:items (map (lambda (item) :items (map (lambda (item)
(let* ((nl (get item "newsletter")) (let* ((nl (get item "newsletter"))
(un (get item "un")) (un (get item "un"))
@@ -47,11 +47,11 @@
(bg (if subscribed "bg-emerald-500" "bg-stone-300")) (bg (if subscribed "bg-emerald-500" "bg-stone-300"))
(translate (if subscribed "translate-x-6" "translate-x-1")) (translate (if subscribed "translate-x-6" "translate-x-1"))
(checked (if subscribed "true" "false"))) (checked (if subscribed "true" "false")))
(~account-newsletter-item (~newsletters/item
:name (get nl "name") :name (get nl "name")
:desc (when (get nl "description") :desc (when (get nl "description")
(~account-newsletter-desc :description (get nl "description"))) (~newsletters/desc :description (get nl "description")))
:toggle (~account-newsletter-toggle :toggle (~newsletters/toggle
:id (str "nl-" nid) :id (str "nl-" nid)
:url toggle-url :url toggle-url
:hdrs {:X-CSRFToken csrf} :hdrs {:X-CSRFToken csrf}

View File

@@ -8,7 +8,7 @@
:path "/" :path "/"
:auth :login :auth :login
:layout :account :layout :account
:content (~account-dashboard-content)) :content (~dashboard/content))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Newsletters ;; Newsletters
@@ -19,7 +19,7 @@
:auth :login :auth :login
:layout :account :layout :account
:data (service "account-page" "newsletters-data") :data (service "account-page" "newsletters-data")
:content (~account-newsletters-content :content (~newsletters/content
:newsletter-list newsletter-list :newsletter-list newsletter-list
:account-url account-url)) :account-url account-url))

View File

@@ -256,7 +256,7 @@ def _image(node: dict) -> str:
parts.append(f':width "{_esc(width)}"') parts.append(f':width "{_esc(width)}"')
if href: if href:
parts.append(f':href "{_esc(href)}"') parts.append(f':href "{_esc(href)}"')
return "(~kg-image " + " ".join(parts) + ")" return "(~kg_cards/kg-image " + " ".join(parts) + ")"
@_converter("gallery") @_converter("gallery")
@@ -282,14 +282,14 @@ def _gallery(node: dict) -> str:
images_sx = "(list " + " ".join(rows) + ")" images_sx = "(list " + " ".join(rows) + ")"
caption = node.get("caption", "") caption = node.get("caption", "")
caption_attr = f" :caption {html_to_sx(caption)}" if caption else "" caption_attr = f" :caption {html_to_sx(caption)}" if caption else ""
return f"(~kg-gallery :images {images_sx}{caption_attr})" return f"(~kg_cards/kg-gallery :images {images_sx}{caption_attr})"
@_converter("html") @_converter("html")
def _html_card(node: dict) -> str: def _html_card(node: dict) -> str:
raw = node.get("html", "") raw = node.get("html", "")
inner = html_to_sx(raw) inner = html_to_sx(raw)
return f"(~kg-html {inner})" return f"(~kg_cards/kg-html {inner})"
@_converter("embed") @_converter("embed")
@@ -299,7 +299,7 @@ def _embed(node: dict) -> str:
parts = [f':html "{_esc(embed_html)}"'] parts = [f':html "{_esc(embed_html)}"']
if caption: if caption:
parts.append(f":caption {html_to_sx(caption)}") parts.append(f":caption {html_to_sx(caption)}")
return "(~kg-embed " + " ".join(parts) + ")" return "(~kg_cards/kg-embed " + " ".join(parts) + ")"
@_converter("bookmark") @_converter("bookmark")
@@ -330,7 +330,7 @@ def _bookmark(node: dict) -> str:
if caption: if caption:
parts.append(f":caption {html_to_sx(caption)}") parts.append(f":caption {html_to_sx(caption)}")
return "(~kg-bookmark " + " ".join(parts) + ")" return "(~kg_cards/kg-bookmark " + " ".join(parts) + ")"
@_converter("callout") @_converter("callout")
@@ -344,7 +344,7 @@ def _callout(node: dict) -> str:
parts.append(f':emoji "{_esc(emoji)}"') parts.append(f':emoji "{_esc(emoji)}"')
if inner: if inner:
parts.append(f':content {inner}') parts.append(f':content {inner}')
return "(~kg-callout " + " ".join(parts) + ")" return "(~kg_cards/kg-callout " + " ".join(parts) + ")"
@_converter("button") @_converter("button")
@@ -352,7 +352,7 @@ def _button(node: dict) -> str:
text = node.get("buttonText", "") text = node.get("buttonText", "")
url = node.get("buttonUrl", "") url = node.get("buttonUrl", "")
alignment = node.get("alignment", "center") alignment = node.get("alignment", "center")
return f'(~kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")' return f'(~kg_cards/kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
@_converter("toggle") @_converter("toggle")
@@ -360,7 +360,7 @@ def _toggle(node: dict) -> str:
heading = node.get("heading", "") heading = node.get("heading", "")
inner = _convert_children(node.get("children", [])) inner = _convert_children(node.get("children", []))
content_attr = f" :content {inner}" if inner else "" content_attr = f" :content {inner}" if inner else ""
return f'(~kg-toggle :heading "{_esc(heading)}"{content_attr})' return f'(~kg_cards/kg-toggle :heading "{_esc(heading)}"{content_attr})'
@_converter("audio") @_converter("audio")
@@ -380,7 +380,7 @@ def _audio(node: dict) -> str:
parts.append(f':duration "{duration_str}"') parts.append(f':duration "{duration_str}"')
if thumbnail: if thumbnail:
parts.append(f':thumbnail "{_esc(thumbnail)}"') parts.append(f':thumbnail "{_esc(thumbnail)}"')
return "(~kg-audio " + " ".join(parts) + ")" return "(~kg_cards/kg-audio " + " ".join(parts) + ")"
@_converter("video") @_converter("video")
@@ -400,7 +400,7 @@ def _video(node: dict) -> str:
parts.append(f':thumbnail "{_esc(thumbnail)}"') parts.append(f':thumbnail "{_esc(thumbnail)}"')
if loop: if loop:
parts.append(":loop true") parts.append(":loop true")
return "(~kg-video " + " ".join(parts) + ")" return "(~kg_cards/kg-video " + " ".join(parts) + ")"
@_converter("file") @_converter("file")
@@ -429,12 +429,12 @@ def _file(node: dict) -> str:
parts.append(f':filesize "{size_str}"') parts.append(f':filesize "{size_str}"')
if caption: if caption:
parts.append(f":caption {html_to_sx(caption)}") parts.append(f":caption {html_to_sx(caption)}")
return "(~kg-file " + " ".join(parts) + ")" return "(~kg_cards/kg-file " + " ".join(parts) + ")"
@_converter("paywall") @_converter("paywall")
def _paywall(_node: dict) -> str: def _paywall(_node: dict) -> str:
return "(~kg-paywall)" return "(~kg_cards/kg-paywall)"
@_converter("markdown") @_converter("markdown")
@@ -442,4 +442,4 @@ def _markdown(node: dict) -> str:
md_text = node.get("markdown", "") md_text = node.get("markdown", "")
rendered = mistune.html(md_text) rendered = mistune.html(md_text)
inner = html_to_sx(rendered) inner = html_to_sx(rendered)
return f"(~kg-md {inner})" return f"(~kg_cards/kg-md {inner})"

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Re-convert sx_content from lexical JSON to eliminate ~kg-html wrappers and Re-convert sx_content from lexical JSON to eliminate ~kg_cards/kg-html wrappers and
raw caption strings. raw caption strings.
The updated lexical_to_sx converter now produces native sx expressions instead The updated lexical_to_sx converter now produces native sx expressions instead
of (1) wrapping HTML/markdown cards in (~kg-html :html "...") and (2) storing of (1) wrapping HTML/markdown cards in (~kg_cards/kg-html :html "...") and (2) storing
captions as escaped HTML strings. This script re-runs the conversion on all captions as escaped HTML strings. This script re-runs the conversion on all
posts that already have sx_content, overwriting the old output. posts that already have sx_content, overwriting the old output.
@@ -50,11 +50,11 @@ async def migrate(dry_run: bool = False) -> int:
continue continue
if dry_run: if dry_run:
old_has_kg = "~kg-html" in (post.sx_content or "") old_has_kg = "~kg_cards/kg-html" in (post.sx_content or "")
old_has_raw = "raw! caption" in (post.sx_content or "") old_has_raw = "raw! caption" in (post.sx_content or "")
markers = [] markers = []
if old_has_kg: if old_has_kg:
markers.append("~kg-html") markers.append("~kg_cards/kg-html")
if old_has_raw: if old_has_raw:
markers.append("raw-caption") markers.append("raw-caption")
tag = f" [{', '.join(markers)}]" if markers else "" tag = f" [{', '.join(markers)}]" if markers else ""
@@ -76,7 +76,7 @@ async def migrate(dry_run: bool = False) -> int:
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Re-convert sx_content to eliminate ~kg-html and raw captions" description="Re-convert sx_content to eliminate ~kg_cards/kg-html and raw captions"
) )
parser.add_argument("--dry-run", action="store_true", parser.add_argument("--dry-run", action="store_true",
help="Preview changes without writing to database") help="Preview changes without writing to database")

View File

@@ -398,7 +398,7 @@ class BlogPageService:
} }
def post_detail_data(self, post, user, rights, csrf, blog_url_base): def post_detail_data(self, post, user, rights, csrf, blog_url_base):
"""Serialize post detail view data for ~blog-post-detail-content defcomp.""" """Serialize post detail view data for ~detail/post-detail-content defcomp."""
slug = post.get("slug", "") slug = post.get("slug", "")
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
user_id = getattr(user, "id", None) if user else None user_id = getattr(user, "id", None) if user else None

View File

@@ -1,6 +1,6 @@
;; Blog admin panel components ;; Blog admin panel components
(defcomp ~blog-cache-panel (&key (clear-url :as string) (csrf :as string)) (defcomp ~admin/cache-panel (&key (clear-url :as string) (csrf :as string))
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6" (div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
(div :class "flex flex-col md:flex-row gap-3 items-start" (div :class "flex flex-col md:flex-row gap-3 items-start"
(form :sx-post clear-url :sx-trigger "submit" :sx-target "#cache-status" :sx-swap "innerHTML" (form :sx-post clear-url :sx-trigger "submit" :sx-target "#cache-status" :sx-swap "innerHTML"
@@ -8,21 +8,21 @@
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache")) (button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
(div :id "cache-status" :class "py-2")))) (div :id "cache-status" :class "py-2"))))
(defcomp ~blog-snippets-panel (&key list) (defcomp ~admin/snippets-panel (&key list)
(div :class "max-w-4xl mx-auto p-6" (div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-between items-center" (div :class "mb-6 flex justify-between items-center"
(h1 :class "text-3xl font-bold" "Snippets")) (h1 :class "text-3xl font-bold" "Snippets"))
(div :id "snippets-list" list))) (div :id "snippets-list" list)))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls) (defcomp ~admin/snippet-visibility-select (&key patch-url hx-headers options cls)
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML" (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" :sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
options)) options))
(defcomp ~blog-snippet-option (&key (value :as string) (selected :as boolean) (label :as string)) (defcomp ~admin/snippet-option (&key (value :as string) (selected :as boolean) (label :as string))
(option :value value :selected selected label)) (option :value value :selected selected label))
(defcomp ~blog-snippet-row (&key (name :as string) (owner :as string) (badge-cls :as string) (visibility :as string) extra) (defcomp ~admin/snippet-row (&key (name :as string) (owner :as string) (badge-cls :as string) (visibility :as string) extra)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition" (div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name) (div :class "font-medium truncate" name)
@@ -30,10 +30,10 @@
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility) (span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
extra)) extra))
(defcomp ~blog-snippets-list (&key rows) (defcomp ~admin/snippets-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows))) (div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
(defcomp ~blog-menu-items-panel (&key new-url list) (defcomp ~admin/menu-items-panel (&key new-url list)
(div :class "max-w-4xl mx-auto p-6" (div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-end items-center" (div :class "mb-6 flex justify-end items-center"
(button :type "button" :sx-get new-url :sx-target "#menu-item-form" :sx-swap "innerHTML" (button :type "button" :sx-get new-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
@@ -42,7 +42,7 @@
(div :id "menu-item-form" :class "mb-6") (div :id "menu-item-form" :class "mb-6")
(div :id "menu-items-list" list))) (div :id "menu-items-list" list)))
(defcomp ~blog-menu-item-row (&key img (label :as string) (slug :as string) (sort-order :as string) (edit-url :as string) (delete-url :as string) (confirm-text :as string) hx-headers) (defcomp ~admin/menu-item-row (&key img (label :as string) (slug :as string) (sort-order :as string) (edit-url :as string) (delete-url :as string) (confirm-text :as string) hx-headers)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition" (div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical")) (div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
img img
@@ -54,16 +54,16 @@
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML" (button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded" :class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
(i :class "fa fa-edit") " Edit") (i :class "fa fa-edit") " Edit")
(~delete-btn :url delete-url :trigger-target "#menu-items-list" (~shared:misc/delete-btn :url delete-url :trigger-target "#menu-items-list"
:title "Delete menu item?" :text confirm-text :title "Delete menu item?" :text confirm-text
:sx-headers hx-headers)))) :sx-headers hx-headers))))
(defcomp ~blog-menu-items-list (&key rows) (defcomp ~admin/menu-items-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows))) (div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
;; Tag groups admin ;; Tag groups admin
(defcomp ~blog-tag-groups-create-form (&key create-url csrf) (defcomp ~admin/tag-groups-create-form (&key create-url csrf)
(form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3" (form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(h3 :class "text-sm font-semibold text-stone-700" "New Group") (h3 :class "text-sm font-semibold text-stone-700" "New Group")
@@ -74,14 +74,14 @@
(input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full 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"))) (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) (defcomp ~admin/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")) (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) (defcomp ~admin/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" (div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
:style style initial)) :style style initial))
(defcomp ~blog-tag-group-li (&key icon (edit-href :as string) (name :as string) (slug :as string) (sort-order :as number)) (defcomp ~admin/tag-group-li (&key icon (edit-href :as string) (name :as string) (slug :as string) (sort-order :as number))
(li :class "border rounded p-3 bg-white flex items-center gap-3" (li :class "border rounded p-3 bg-white flex items-center gap-3"
icon icon
(div :class "flex-1" (div :class "flex-1"
@@ -89,32 +89,32 @@
(span :class "text-xs text-stone-500 ml-2" slug)) (span :class "text-xs text-stone-500 ml-2" slug))
(span :class "text-xs text-stone-500" (str "order: " sort-order)))) (span :class "text-xs text-stone-500" (str "order: " sort-order))))
(defcomp ~blog-tag-groups-list (&key items) (defcomp ~admin/tag-groups-list (&key items)
(ul :class "space-y-2" items)) (ul :class "space-y-2" items))
(defcomp ~blog-unassigned-tag (&key name) (defcomp ~admin/unassigned-tag (&key name)
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name)) (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
(defcomp ~blog-unassigned-tags (&key heading spans) (defcomp ~admin/unassigned-tags (&key heading spans)
(div :class "border-t pt-4" (div :class "border-t pt-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading) (h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
(div :class "flex flex-wrap gap-2" spans))) (div :class "flex flex-wrap gap-2" spans)))
(defcomp ~blog-tag-groups-main (&key form groups unassigned) (defcomp ~admin/tag-groups-main (&key form groups unassigned)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8" (div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
form groups unassigned)) form groups unassigned))
;; Tag group edit ;; Tag group edit
(defcomp ~blog-tag-checkbox (&key (tag-id :as string) (checked :as boolean) img (name :as string)) (defcomp ~admin/tag-checkbox (&key (tag-id :as string) (checked :as boolean) img (name :as string))
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer" (label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300") (input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
img (span name))) img (span name)))
(defcomp ~blog-tag-checkbox-image (&key src) (defcomp ~admin/tag-checkbox-image (&key src)
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover")) (img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
(defcomp ~blog-tag-group-edit-form (&key (save-url :as string) (csrf :as string) (name :as string) (colour :as string?) (sort-order :as number) (feature-image :as string?) tags) (defcomp ~admin/tag-group-edit-form (&key (save-url :as string) (csrf :as string) (name :as string) (colour :as string?) (sort-order :as number) (feature-image :as string?) tags)
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4" (form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(div :class "space-y-3" (div :class "space-y-3"
@@ -133,19 +133,19 @@
(div :class "flex gap-3" (div :class "flex gap-3"
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save")))) (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
(defcomp ~blog-tag-group-delete-form (&key (delete-url :as string) (csrf :as string)) (defcomp ~admin/tag-group-delete-form (&key (delete-url :as string) (csrf :as string))
(form :method "post" :action delete-url :class "border-t pt-4" (form :method "post" :action delete-url :class "border-t pt-4"
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')" :onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group"))) (button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form) (defcomp ~admin/tag-group-edit-main (&key edit-form delete-form)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6" (div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form)) edit-form delete-form))
;; Data-driven snippets list (replaces Python _snippets_sx loop) ;; Data-driven snippets list (replaces Python _snippets_sx loop)
(defcomp ~blog-snippets-from-data (&key snippets user-id is-admin csrf badge-colours) (defcomp ~admin/snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
(~blog-snippets-list (~admin/snippets-list
:rows (<> (map (lambda (s) :rows (<> (map (lambda (s)
(let* ((s-id (get s "id")) (let* ((s-id (get s "id"))
(s-name (get s "name")) (s-name (get s "name"))
@@ -155,31 +155,31 @@
(badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700")) (badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
(extra (<> (extra (<>
(when is-admin (when is-admin
(~blog-snippet-visibility-select (~admin/snippet-visibility-select
:patch-url (get s "patch_url") :patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<> :options (<>
(~blog-snippet-option :value "private" :selected (= s-vis "private") :label "private") (~admin/snippet-option :value "private" :selected (= s-vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= s-vis "shared") :label "shared") (~admin/snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= s-vis "admin") :label "admin")) (~admin/snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
:cls "text-sm border border-stone-300 rounded px-2 py-1")) :cls "text-sm border border-stone-300 rounded px-2 py-1"))
(when (or (= s-uid user-id) is-admin) (when (or (= s-uid user-id) is-admin)
(~delete-btn :url (get s "delete_url") :trigger-target "#snippets-list" (~shared:misc/delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
:title "Delete snippet?" :title "Delete snippet?"
:text (str "Delete \u201c" s-name "\u201d?") :text (str "Delete \u201c" s-name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}") :sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))) :cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
(~blog-snippet-row :name s-name :owner owner :badge-cls badge-cls (~admin/snippet-row :name s-name :owner owner :badge-cls badge-cls
:visibility s-vis :extra extra))) :visibility s-vis :extra extra)))
(or snippets (list)))))) (or snippets (list))))))
;; Data-driven menu items list (replaces Python _menu_items_list_sx loop) ;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
(defcomp ~blog-menu-items-from-data (&key items csrf) (defcomp ~admin/menu-items-from-data (&key items csrf)
(~blog-menu-items-list (~admin/menu-items-list
:rows (<> (map (lambda (item) :rows (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label") (let* ((img (~shared:misc/img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0"))) :size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")))
(~blog-menu-item-row (~admin/menu-item-row
:img img :label (get item "label") :slug (get item "slug") :img img :label (get item "label") :slug (get item "slug")
:sort-order (get item "sort_order") :edit-url (get item "edit_url") :sort-order (get item "sort_order") :edit-url (get item "edit_url")
:delete-url (get item "delete_url") :delete-url (get item "delete_url")
@@ -188,38 +188,38 @@
(or items (list)))))) (or items (list))))))
;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops) ;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops)
(defcomp ~blog-tag-groups-from-data (&key groups unassigned-tags csrf create-url) (defcomp ~admin/tag-groups-from-data (&key groups unassigned-tags csrf create-url)
(~blog-tag-groups-main (~admin/tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf) :form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list))) :groups (if (empty? (or groups (list)))
(~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm") (~shared:misc/empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
(~blog-tag-groups-list (~admin/tag-groups-list
:items (<> (map (lambda (g) :items (<> (map (lambda (g)
(let* ((icon (if (get g "feature_image") (let* ((icon (if (get g "feature_image")
(~blog-tag-group-icon-image :src (get g "feature_image") :name (get g "name")) (~admin/tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
(~blog-tag-group-icon-color :style (get g "style") :initial (get g "initial"))))) (~admin/tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-tag-group-li :icon icon :edit-href (get g "edit_href") (~admin/tag-group-li :icon icon :edit-href (get g "edit_href")
:name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order")))) :name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order"))))
groups)))) groups))))
:unassigned (when (not (empty? (or unassigned-tags (list)))) :unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags (~admin/unassigned-tags
:heading (str "Unassigned Tags (" (len unassigned-tags) ")") :heading (str "Unassigned Tags (" (len unassigned-tags) ")")
:spans (<> (map (lambda (t) :spans (<> (map (lambda (t)
(~blog-unassigned-tag :name (get t "name"))) (~admin/unassigned-tag :name (get t "name")))
unassigned-tags)))))) unassigned-tags))))))
;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop) ;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop)
(defcomp ~blog-tag-checkboxes-from-data (&key tags) (defcomp ~admin/tag-checkboxes-from-data (&key tags)
(<> (map (lambda (t) (<> (map (lambda (t)
(~blog-tag-checkbox (~admin/tag-checkbox
:tag-id (get t "tag_id") :checked (get t "checked") :tag-id (get t "tag_id") :checked (get t "checked")
:img (when (get t "feature_image") (~blog-tag-checkbox-image :src (get t "feature_image"))) :img (when (get t "feature_image") (~admin/tag-checkbox-image :src (get t "feature_image")))
:name (get t "name"))) :name (get t "name")))
(or tags (list))))) (or tags (list)))))
;; Preview panel components ;; Preview panel components
(defcomp ~blog-preview-panel (&key sections) (defcomp ~admin/preview-panel (&key sections)
(div :class "max-w-4xl mx-auto px-4 py-6 space-y-4" (div :class "max-w-4xl mx-auto px-4 py-6 space-y-4"
(style " (style "
.sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; } .sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
@@ -239,18 +239,18 @@
") ")
sections)) sections))
(defcomp ~blog-preview-section (&key title content) (defcomp ~admin/preview-section (&key title content)
(details :class "border rounded bg-white" (details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title) (summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content))) (div :class "p-4 overflow-x-auto text-xs" content)))
(defcomp ~blog-preview-rendered (&key html) (defcomp ~admin/preview-rendered (&key html)
(div :class "blog-content prose max-w-none" (raw! html))) (div :class "blog-content prose max-w-none" (raw! html)))
(defcomp ~blog-preview-empty () (defcomp ~admin/preview-empty ()
(div :class "p-8 text-stone-500" "No content to preview.")) (div :class "p-8 text-stone-500" "No content to preview."))
(defcomp ~blog-admin-placeholder () (defcomp ~admin/placeholder ()
(div :class "pb-8")) (div :class "pb-8"))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@@ -258,12 +258,12 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Snippets — receives serialized snippet dicts from service ;; Snippets — receives serialized snippet dicts from service
(defcomp ~blog-snippets-content (&key snippets is-admin csrf) (defcomp ~admin/snippets-content (&key snippets is-admin csrf)
(~blog-snippets-panel (~admin/snippets-panel
:list (if (empty? (or snippets (list))) :list (if (empty? (or snippets (list)))
(~empty-state :icon "fa fa-puzzle-piece" (~shared:misc/empty-state :icon "fa fa-puzzle-piece"
:message "No snippets yet. Create one from the blog editor.") :message "No snippets yet. Create one from the blog editor.")
(~blog-snippets-list (~admin/snippets-list
:rows (map (lambda (s) :rows (map (lambda (s)
(let* ((badge-colours (dict (let* ((badge-colours (dict
"private" "bg-stone-200 text-stone-700" "private" "bg-stone-200 text-stone-700"
@@ -274,19 +274,19 @@
(name (get s "name")) (name (get s "name"))
(owner (get s "owner")) (owner (get s "owner"))
(can-delete (get s "can_delete"))) (can-delete (get s "can_delete")))
(~blog-snippet-row (~admin/snippet-row
:name name :owner owner :badge-cls badge-cls :visibility vis :name name :owner owner :badge-cls badge-cls :visibility vis
:extra (<> :extra (<>
(when is-admin (when is-admin
(~blog-snippet-visibility-select (~admin/snippet-visibility-select
:patch-url (get s "patch_url") :patch-url (get s "patch_url")
:hx-headers {:X-CSRFToken csrf} :hx-headers {:X-CSRFToken csrf}
:options (<> :options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private") (~admin/snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared") (~admin/snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin")))) (~admin/snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(when can-delete (when can-delete
(~delete-btn (~shared:misc/delete-btn
:url (get s "delete_url") :url (get s "delete_url")
:trigger-target "#snippets-list" :trigger-target "#snippets-list"
:title "Delete snippet?" :title "Delete snippet?"
@@ -296,16 +296,16 @@
(or snippets (list))))))) (or snippets (list)))))))
;; Menu Items — receives serialized menu item dicts from service ;; Menu Items — receives serialized menu item dicts from service
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf) (defcomp ~admin/menu-items-content (&key menu-items new-url csrf)
(~blog-menu-items-panel (~admin/menu-items-panel
:new-url new-url :new-url new-url
:list (if (empty? (or menu-items (list))) :list (if (empty? (or menu-items (list)))
(~empty-state :icon "fa fa-inbox" (~shared:misc/empty-state :icon "fa fa-inbox"
:message "No menu items yet. Add one to get started!") :message "No menu items yet. Add one to get started!")
(~blog-menu-items-list (~admin/menu-items-list
:rows (map (lambda (mi) :rows (map (lambda (mi)
(~blog-menu-item-row (~admin/menu-item-row
:img (~img-or-placeholder :img (~shared:misc/img-or-placeholder
:src (get mi "feature_image") :alt (get mi "label") :src (get mi "feature_image") :alt (get mi "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0") :size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
:label (get mi "label") :label (get mi "label")
@@ -318,23 +318,23 @@
(or menu-items (list))))))) (or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service ;; Tag Groups — receives serialized tag group data from service
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf) (defcomp ~admin/tag-groups-content (&key groups unassigned-tags create-url csrf)
(~blog-tag-groups-main (~admin/tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf) :form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list))) :groups (if (empty? (or groups (list)))
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.") (~shared:misc/empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~blog-tag-groups-list (~admin/tag-groups-list
:items (map (lambda (g) :items (map (lambda (g)
(let* ((fi (get g "feature_image")) (let* ((fi (get g "feature_image"))
(colour (get g "colour")) (colour (get g "colour"))
(name (get g "name")) (name (get g "name"))
(initial (slice (or name "?") 0 1)) (initial (slice (or name "?") 0 1))
(icon (if fi (icon (if fi
(~blog-tag-group-icon-image :src fi :name name) (~admin/tag-group-icon-image :src fi :name name)
(~blog-tag-group-icon-color (~admin/tag-group-icon-color
:style (if colour (str "background:" colour) "background:#e7e5e4") :style (if colour (str "background:" colour) "background:#e7e5e4")
:initial initial)))) :initial initial))))
(~blog-tag-group-li (~admin/tag-group-li
:icon icon :icon icon
:edit-href (get g "edit_href") :edit-href (get g "edit_href")
:name name :name name
@@ -342,57 +342,57 @@
:sort-order (or (get g "sort_order") 0)))) :sort-order (or (get g "sort_order") 0))))
(or groups (list))))) (or groups (list)))))
:unassigned (when (not (empty? (or unassigned-tags (list)))) :unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags (~admin/unassigned-tags
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags") :heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
:spans (map (lambda (t) :spans (map (lambda (t)
(~blog-unassigned-tag :name (get t "name"))) (~admin/unassigned-tag :name (get t "name")))
(or unassigned-tags (list))))))) (or unassigned-tags (list)))))))
;; Tag Group Edit — receives serialized tag group + tags from service ;; Tag Group Edit — receives serialized tag group + tags from service
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf) (defcomp ~admin/tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~blog-tag-group-edit-main (~admin/tag-group-edit-main
:edit-form (~blog-tag-group-edit-form :edit-form (~admin/tag-group-edit-form
:save-url save-url :csrf csrf :save-url save-url :csrf csrf
:name (get group "name") :name (get group "name")
:colour (get group "colour") :colour (get group "colour")
:sort-order (get group "sort_order") :sort-order (get group "sort_order")
:feature-image (get group "feature_image") :feature-image (get group "feature_image")
:tags (map (lambda (t) :tags (map (lambda (t)
(~blog-tag-checkbox (~admin/tag-checkbox
:tag-id (get t "id") :tag-id (get t "id")
:checked (get t "checked") :checked (get t "checked")
:img (when (get t "feature_image") :img (when (get t "feature_image")
(~blog-tag-checkbox-image :src (get t "feature_image"))) (~admin/tag-checkbox-image :src (get t "feature_image")))
:name (get t "name"))) :name (get t "name")))
(or all-tags (list)))) (or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf))) :delete-form (~admin/tag-group-delete-form :delete-url delete-url :csrf csrf)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Preview content composition — replaces _h_post_preview_content ;; Preview content composition — replaces _h_post_preview_content
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered) (defcomp ~admin/preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
(let* ((sections (list))) (let* ((sections (list)))
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered)) (if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
(~blog-preview-empty) (~admin/preview-empty)
(~blog-preview-panel :sections (~admin/preview-panel :sections
(<> (<>
(when sx-pretty (when sx-pretty
(~blog-preview-section :title "S-Expression Source" :content sx-pretty)) (~admin/preview-section :title "S-Expression Source" :content sx-pretty))
(when json-pretty (when json-pretty
(~blog-preview-section :title "Lexical JSON" :content json-pretty)) (~admin/preview-section :title "Lexical JSON" :content json-pretty))
(when sx-rendered (when sx-rendered
(~blog-preview-section :title "SX Rendered" (~admin/preview-section :title "SX Rendered"
:content (~blog-preview-rendered :html sx-rendered))) :content (~admin/preview-rendered :html sx-rendered)))
(when lex-rendered (when lex-rendered
(~blog-preview-section :title "Lexical Rendered" (~admin/preview-section :title "Lexical Rendered"
:content (~blog-preview-rendered :html lex-rendered)))))))) :content (~admin/preview-rendered :html lex-rendered))))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Data introspection composition — replaces _h_post_data_content ;; Data introspection composition — replaces _h_post_data_content
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-data-value-cell (&key value value-type) (defcomp ~admin/data-value-cell (&key value value-type)
(if (= value-type "nil") (if (= value-type "nil")
(span :class "text-neutral-400" "\u2014") (span :class "text-neutral-400" "\u2014")
(pre :class "whitespace-pre-wrap break-words break-all text-xs" (pre :class "whitespace-pre-wrap break-words break-all text-xs"
@@ -400,7 +400,7 @@
(code value) (code value)
value)))) value))))
(defcomp ~blog-data-scalar-table (&key columns) (defcomp ~admin/data-scalar-table (&key columns)
(div :class "w-full overflow-x-auto sm:overflow-visible" (div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden" (table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
(thead :class "bg-neutral-50/70" (thead :class "bg-neutral-50/70"
@@ -411,10 +411,10 @@
(tr :class "border-t border-neutral-200 align-top" (tr :class "border-t border-neutral-200 align-top"
(td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key")) (td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key"))
(td :class "px-3 py-2 align-top" (td :class "px-3 py-2 align-top"
(~blog-data-value-cell :value (get col "value") :value-type (get col "type"))))) (~admin/data-value-cell :value (get col "value") :value-type (get col "type")))))
(or columns (list))))))) (or columns (list)))))))
(defcomp ~blog-data-relationship-item (&key index summary children) (defcomp ~admin/data-relationship-item (&key index summary children)
(tr :class "border-t border-neutral-200 align-top" (tr :class "border-t border-neutral-200 align-top"
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index)) (td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
(td :class "px-2 py-1 align-top" (td :class "px-2 py-1 align-top"
@@ -422,11 +422,11 @@
(code summary)) (code summary))
(when children (when children
(div :class "mt-2 pl-3 border-l border-neutral-200" (div :class "mt-2 pl-3 border-l border-neutral-200"
(~blog-data-model-content (~admin/data-model-content
:columns (get children "columns") :columns (get children "columns")
:relationships (get children "relationships"))))))) :relationships (get children "relationships")))))))
(defcomp ~blog-data-relationship (&key name cardinality class-name loaded value) (defcomp ~admin/data-relationship (&key name cardinality class-name loaded value)
(div :class "rounded-xl border border-neutral-200" (div :class "rounded-xl border border-neutral-200"
(div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium" (div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
"Relationship: " (span :class "font-semibold" name) "Relationship: " (span :class "font-semibold" name)
@@ -448,7 +448,7 @@
(th :class "px-2 py-1 text-left" "Summary"))) (th :class "px-2 py-1 text-left" "Summary")))
(tbody (tbody
(map (lambda (item) (map (lambda (item)
(~blog-data-relationship-item (~admin/data-relationship-item
:index (get item "index") :index (get item "index")
:summary (get item "summary") :summary (get item "summary")
:children (get item "children"))) :children (get item "children")))
@@ -459,17 +459,17 @@
(code (get value "summary"))) (code (get value "summary")))
(when (get value "children") (when (get value "children")
(div :class "pl-3 border-l border-neutral-200" (div :class "pl-3 border-l border-neutral-200"
(~blog-data-model-content (~admin/data-model-content
:columns (get (get value "children") "columns") :columns (get (get value "children") "columns")
:relationships (get (get value "children") "relationships")))))))))) :relationships (get (get value "children") "relationships"))))))))))
(defcomp ~blog-data-model-content (&key columns relationships) (defcomp ~admin/data-model-content (&key columns relationships)
(div :class "space-y-4" (div :class "space-y-4"
(~blog-data-scalar-table :columns columns) (~admin/data-scalar-table :columns columns)
(when (not (empty? (or relationships (list)))) (when (not (empty? (or relationships (list))))
(div :class "space-y-3" (div :class "space-y-3"
(map (lambda (rel) (map (lambda (rel)
(~blog-data-relationship (~admin/data-relationship
:name (get rel "name") :name (get rel "name")
:cardinality (get rel "cardinality") :cardinality (get rel "cardinality")
:class-name (get rel "class_name") :class-name (get rel "class_name")
@@ -477,13 +477,13 @@
:value (get rel "value"))) :value (get rel "value")))
relationships))))) relationships)))))
(defcomp ~blog-data-table-content (&key tablename model-data) (defcomp ~admin/data-table-content (&key tablename model-data)
(if (not model-data) (if (not model-data)
(div :class "px-4 py-8 text-stone-400" "No post data available.") (div :class "px-4 py-8 text-stone-400" "No post data available.")
(div :class "px-4 py-8" (div :class "px-4 py-8"
(div :class "mb-6 text-sm text-neutral-500" (div :class "mb-6 text-sm text-neutral-500"
"Model: " (code "Post") " \u2022 Table: " (code tablename)) "Model: " (code "Post") " \u2022 Table: " (code tablename))
(~blog-data-model-content (~admin/data-model-content
:columns (get model-data "columns") :columns (get model-data "columns")
:relationships (get model-data "relationships"))))) :relationships (get model-data "relationships")))))
@@ -491,7 +491,7 @@
;; Calendar month view for browsing/toggling entries (B1) ;; Calendar month view for browsing/toggling entries (B1)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-cal-entry-associated (&key name toggle-url csrf) (defcomp ~admin/cal-entry-associated (&key name toggle-url csrf)
(div :class "flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900" (div :class "flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900"
(span :class "truncate flex-1" name) (span :class "truncate flex-1" name)
(button :type "button" :class "flex-shrink-0 hover:text-red-600" (button :type "button" :class "flex-shrink-0 hover:text-red-600"
@@ -505,7 +505,7 @@
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))" :sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(i :class "fa fa-times")))) (i :class "fa fa-times"))))
(defcomp ~blog-cal-entry-unassociated (&key name toggle-url csrf) (defcomp ~admin/cal-entry-unassociated (&key name toggle-url csrf)
(button :type "button" (button :type "button"
:class "w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200" :class "w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"
:data-confirm "" :data-confirm-title "Add entry?" :data-confirm "" :data-confirm-title "Add entry?"
@@ -518,7 +518,7 @@
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))" :sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(span :class "truncate block" name))) (span :class "truncate block" name)))
(defcomp ~blog-calendar-view (&key cal-id year month-name (defcomp ~admin/calendar-view (&key cal-id year month-name
current-url prev-month-url prev-year-url current-url prev-month-url prev-year-url
next-month-url next-year-url next-month-url next-year-url
weekday-names days csrf) weekday-names days csrf)
@@ -553,9 +553,9 @@
(div :class "space-y-0.5" (div :class "space-y-0.5"
(map (lambda (e) (map (lambda (e)
(if (get e "is_associated") (if (get e "is_associated")
(~blog-cal-entry-associated (~admin/cal-entry-associated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf) :name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)
(~blog-cal-entry-unassociated (~admin/cal-entry-unassociated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf))) :name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
entries)))))) entries))))))
(or days (list)))))))) (or days (list))))))))
@@ -564,15 +564,15 @@
;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2) ;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-nav-entries-oob (&key entries calendars) (defcomp ~admin/nav-entries-oob (&key entries calendars)
(let* ((entry-list (or entries (list))) (let* ((entry-list (or entries (list)))
(cal-list (or calendars (list))) (cal-list (or calendars (list)))
(has-items (or (not (empty? entry-list)) (not (empty? cal-list)))) (has-items (or (not (empty? entry-list)) (not (empty? cal-list))))
(nav-cls "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black [.hover-capable_&]:hover:bg-yellow-300 aria-selected:bg-stone-500 aria-selected:text-white [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500 p-2") (nav-cls "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black [.hover-capable_&]:hover:bg-yellow-300 aria-selected:bg-stone-500 aria-selected:text-white [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500 p-2")
(scroll-hs "on load or scroll if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end")) (scroll-hs "on load or scroll if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"))
(if (not has-items) (if (not has-items)
(~blog-nav-entries-empty) (~shared:nav/blog-nav-entries-empty)
(~scroll-nav-wrapper (~shared:misc/scroll-nav-wrapper
:wrapper-id "entries-calendars-nav-wrapper" :wrapper-id "entries-calendars-nav-wrapper"
:container-id "associated-items-container" :container-id "associated-items-container"
:arrow-cls "entries-nav-arrow" :arrow-cls "entries-nav-arrow"
@@ -581,12 +581,12 @@
:right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200" :right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
:items (<> :items (<>
(map (lambda (e) (map (lambda (e)
(~calendar-entry-nav (~shared:navigation/calendar-entry-nav
:href (get e "href") :nav-class nav-cls :href (get e "href") :nav-class nav-cls
:name (get e "name") :date-str (get e "date_str"))) :name (get e "name") :date-str (get e "date_str")))
entry-list) entry-list)
(map (lambda (c) (map (lambda (c)
(~blog-nav-calendar-item (~shared:nav/blog-nav-calendar-item
:href (get c "href") :nav-cls nav-cls :href (get c "href") :nav-cls nav-cls
:name (get c "name"))) :name (get c "name")))
cal-list)) cal-list))

View File

@@ -1,51 +1,51 @@
;; Blog card components — pure data, no HTML injection ;; Blog card components — pure data, no HTML injection
(defcomp ~blog-like-button (&key like-url hx-headers heart) (defcomp ~cards/like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl" (div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart))) (~detail/like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-draft-status (&key (publish-requested :as boolean) (timestamp :as string?)) (defcomp ~cards/draft-status (&key (publish-requested :as boolean) (timestamp :as string?))
(<> (div :class "flex justify-center gap-2 mt-1" (<> (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") (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 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))))) (when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
(defcomp ~blog-published-status (&key (timestamp :as string)) (defcomp ~cards/published-status (&key (timestamp :as string))
(p :class "text-sm text-stone-500" (str "Published: " timestamp))) (p :class "text-sm text-stone-500" (str "Published: " timestamp)))
;; Tag components — accept data, not HTML ;; Tag components — accept data, not HTML
(defcomp ~blog-tag-icon (&key (src :as string?) (name :as string) (initial :as string)) (defcomp ~cards/tag-icon (&key (src :as string?) (name :as string) (initial :as string))
(if src (if src
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0") (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))) (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) (defcomp ~cards/tag-item (&key src name initial)
(li (a :class "flex items-center gap-1" (li (a :class "flex items-center gap-1"
(~blog-tag-icon :src src :name name :initial initial) (~cards/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)))) (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 ;; At-bar — tags + authors row for detail pages
(defcomp ~blog-at-bar (&key tags authors) (defcomp ~cards/at-bar (&key tags authors)
(when (or tags authors) (when (or tags authors)
(div :class "flex flex-row justify-center gap-3" (div :class "flex flex-row justify-center gap-3"
(when tags (when tags
(div :class "mt-4 flex items-center gap-2" (div "in") (div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm" (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)))) (map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div) (div)
(when authors (when authors
(div :class "mt-4 flex items-center gap-2" (div "by") (div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm" (ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))) (map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors)))))))
;; Author components ;; Author components
(defcomp ~blog-author-item (&key image name) (defcomp ~cards/author-item (&key image name)
(li :class "flex items-center gap-1" (li :class "flex items-center gap-1"
(when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover")) (when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover"))
(span :class "text-stone-700" name))) (span :class "text-stone-700" name)))
;; Card — accepts pure data ;; Card — accepts pure data
(defcomp ~blog-card (&key (slug :as string) (href :as string) (hx-select :as string?) (title :as string) (defcomp ~cards/index (&key (slug :as string) (href :as string) (hx-select :as string?) (title :as string)
(feature-image :as string?) (excerpt :as string?) (feature-image :as string?) (excerpt :as string?)
status (is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?) status (is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?)
(liked :as boolean) (like-url :as string?) (csrf-token :as string?) (liked :as boolean) (like-url :as string?) (csrf-token :as string?)
@@ -53,7 +53,7 @@
(tags :as list?) (authors :as list?) widget) (tags :as list?) (authors :as list?) widget)
(article :class "border-b pb-6 last:border-b-0 relative" (article :class "border-b pb-6 last:border-b-0 relative"
(when has-like (when has-like
(~blog-like-button (~cards/like-button
:like-url like-url :like-url like-url
:hx-headers {:X-CSRFToken csrf-token} :hx-headers {:X-CSRFToken csrf-token}
:heart (if liked "❤️" "🤍"))) :heart (if liked "❤️" "🤍")))
@@ -63,8 +63,8 @@
(header :class "mb-2 text-center" (header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title) (h2 :class "text-4xl font-bold text-stone-900" title)
(if is-draft (if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp) (~cards/draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp)))) (when status-timestamp (~cards/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 feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt))) (when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
widget widget
@@ -73,14 +73,14 @@
(when tags (when tags
(div :class "mt-4 flex items-center gap-2" (div "in") (div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm" (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)))) (map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div) (div)
(when authors (when authors
(div :class "mt-4 flex items-center gap-2" (div "by") (div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm" (ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))) (map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors))))))))
(defcomp ~blog-card-tile (&key (href :as string) (hx-select :as string?) (feature-image :as string?) (title :as string) (defcomp ~cards/tile (&key (href :as string) (hx-select :as string?) (feature-image :as string?) (title :as string)
(is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?) (is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?)
(excerpt :as string?) (tags :as list?) (authors :as list?)) (excerpt :as string?) (tags :as list?) (authors :as list?))
(article :class "relative" (article :class "relative"
@@ -91,33 +91,33 @@
(div :class "p-3 text-center" (div :class "p-3 text-center"
(h2 :class "text-lg font-bold text-stone-900" title) (h2 :class "text-lg font-bold text-stone-900" title)
(if is-draft (if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp) (~cards/draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp))) (when status-timestamp (~cards/published-status :timestamp status-timestamp)))
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt)))) (when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
(when (or tags authors) (when (or tags authors)
(div :class "flex flex-row justify-center gap-3" (div :class "flex flex-row justify-center gap-3"
(when tags (when tags
(div :class "mt-4 flex items-center gap-2" (div "in") (div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm" (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)))) (map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div) (div)
(when authors (when authors
(div :class "mt-4 flex items-center gap-2" (div "by") (div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm" (ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))) (map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors))))))))
;; Data-driven blog cards list (replaces Python _blog_cards_sx loop) ;; Data-driven blog cards list (replaces Python _blog_cards_sx loop)
(defcomp ~blog-cards-from-data (&key (posts :as list?) (view :as string?) sentinel) (defcomp ~cards/from-data (&key (posts :as list?) (view :as string?) sentinel)
(<> (<>
(map (lambda (p) (map (lambda (p)
(if (= view "tile") (if (= view "tile")
(~blog-card-tile (~cards/tile
:href (get p "href") :hx-select (get p "hx_select") :href (get p "href") :hx-select (get p "hx_select")
:feature-image (get p "feature_image") :title (get p "title") :feature-image (get p "feature_image") :title (get p "title")
:is-draft (get p "is_draft") :publish-requested (get p "publish_requested") :is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp") :status-timestamp (get p "status_timestamp")
:excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors")) :excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors"))
(~blog-card (~cards/index
:slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select") :slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select")
:title (get p "title") :feature-image (get p "feature_image") :title (get p "title") :feature-image (get p "feature_image")
:excerpt (get p "excerpt") :is-draft (get p "is_draft") :excerpt (get p "excerpt") :is-draft (get p "is_draft")
@@ -131,10 +131,10 @@
sentinel)) sentinel))
;; Data-driven page cards list (replaces Python _page_cards_sx loop) ;; Data-driven page cards list (replaces Python _page_cards_sx loop)
(defcomp ~page-cards-from-data (&key (pages :as list?) sentinel) (defcomp ~cards/page-cards-from-data (&key (pages :as list?) sentinel)
(<> (<>
(map (lambda (pg) (map (lambda (pg)
(~blog-page-card (~cards/page-card
:href (get pg "href") :hx-select (get pg "hx_select") :href (get pg "href") :hx-select (get pg "hx_select")
:title (get pg "title") :title (get pg "title")
:has-calendar (get pg "has_calendar") :has-market (get pg "has_market") :has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
@@ -143,21 +143,21 @@
(or pages (list))) (or pages (list)))
sentinel)) sentinel))
(defcomp ~blog-page-badges (&key has-calendar has-market) (defcomp ~cards/page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2" (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" (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")) (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" (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")))) (i :class "fa fa-shopping-bag mr-1") "Market"))))
(defcomp ~blog-page-card (&key (href :as string) (hx-select :as string?) (title :as string) (has-calendar :as boolean) (has-market :as boolean) (pub-timestamp :as string?) (feature-image :as string?) (excerpt :as string?)) (defcomp ~cards/page-card (&key (href :as string) (hx-select :as string?) (title :as string) (has-calendar :as boolean) (has-market :as boolean) (pub-timestamp :as string?) (feature-image :as string?) (excerpt :as string?))
(article :class "border-b pb-6 last:border-b-0 relative" (article :class "border-b pb-6 last:border-b-0 relative"
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true" :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" :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center" (header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title) (h2 :class "text-4xl font-bold text-stone-900" title)
(~blog-page-badges :has-calendar has-calendar :has-market has-market) (~cards/page-badges :has-calendar has-calendar :has-market has-market)
(when pub-timestamp (~blog-published-status :timestamp pub-timestamp))) (when pub-timestamp (~cards/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 feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt))))) (when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))

View File

@@ -1,34 +1,34 @@
;; Blog post detail components ;; Blog post detail components
(defcomp ~blog-detail-edit-link (&key (href :as string) (hx-select :as string)) (defcomp ~detail/edit-link (&key (href :as string) (hx-select :as string))
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors" :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
(i :class "fa fa-pencil mr-1") " Edit")) (i :class "fa fa-pencil mr-1") " Edit"))
(defcomp ~blog-detail-draft (&key publish-requested edit) (defcomp ~detail/draft (&key publish-requested edit)
(div :class "flex items-center justify-center gap-2 mb-3" (div :class "flex items-center justify-center gap-2 mb-3"
(span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft") (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested")) (when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
edit)) edit))
(defcomp ~blog-like-toggle (&key like-url hx-headers heart) (defcomp ~detail/like-toggle (&key like-url hx-headers heart)
(button :sx-post like-url :sx-swap "outerHTML" (button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart)) :sx-headers hx-headers :class "cursor-pointer" heart))
(defcomp ~blog-detail-like (&key like-url hx-headers heart) (defcomp ~detail/like (&key like-url hx-headers heart)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl" (div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart))) (~detail/like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-detail-excerpt (&key (excerpt :as string)) (defcomp ~detail/excerpt (&key (excerpt :as string))
(div :class "w-full text-center italic text-3xl p-2" excerpt)) (div :class "w-full text-center italic text-3xl p-2" excerpt))
(defcomp ~blog-detail-chrome (&key like excerpt at-bar) (defcomp ~detail/chrome (&key like excerpt at-bar)
(<> like (<> like
excerpt excerpt
(div :class "hidden md:block" at-bar))) (div :class "hidden md:block" at-bar)))
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content sx-content) (defcomp ~detail/main (&key draft chrome feature-image html-content sx-content)
(<> (article :class "relative" (<> (article :class "relative"
draft draft
chrome chrome
@@ -43,34 +43,34 @@
;; Data-driven composition — replaces _post_main_panel_sx ;; Data-driven composition — replaces _post_main_panel_sx
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-post-detail-content (&key (slug :as string) (is-draft :as boolean) (publish-requested :as boolean) (can-edit :as boolean) (edit-href :as string?) (defcomp ~detail/post-detail-content (&key (slug :as string) (is-draft :as boolean) (publish-requested :as boolean) (can-edit :as boolean) (edit-href :as string?)
(is-page :as boolean) (has-user :as boolean) (liked :as boolean) (like-url :as string?) (csrf :as string?) (is-page :as boolean) (has-user :as boolean) (liked :as boolean) (like-url :as string?) (csrf :as string?)
(custom-excerpt :as string?) (tags :as list?) (authors :as list?) (custom-excerpt :as string?) (tags :as list?) (authors :as list?)
(feature-image :as string?) (html-content :as string?) (sx-content :as string?)) (feature-image :as string?) (html-content :as string?) (sx-content :as string?))
(let* ((hx-select "#main-panel") (let* ((hx-select "#main-panel")
(draft-sx (when is-draft (draft-sx (when is-draft
(~blog-detail-draft (~detail/draft
:publish-requested publish-requested :publish-requested publish-requested
:edit (when can-edit :edit (when can-edit
(~blog-detail-edit-link :href edit-href :hx-select hx-select))))) (~detail/edit-link :href edit-href :hx-select hx-select)))))
(chrome-sx (when (not is-page) (chrome-sx (when (not is-page)
(~blog-detail-chrome (~detail/chrome
:like (when has-user :like (when has-user
(~blog-detail-like (~detail/like
:like-url like-url :like-url like-url
:hx-headers {:X-CSRFToken csrf} :hx-headers {:X-CSRFToken csrf}
:heart (if liked "❤️" "🤍"))) :heart (if liked "❤️" "🤍")))
:excerpt (when (not (= custom-excerpt "")) :excerpt (when (not (= custom-excerpt ""))
(~blog-detail-excerpt :excerpt custom-excerpt)) (~detail/excerpt :excerpt custom-excerpt))
:at-bar (~blog-at-bar :tags tags :authors authors))))) :at-bar (~cards/at-bar :tags tags :authors authors)))))
(~blog-detail-main (~detail/main
:draft draft-sx :draft draft-sx
:chrome chrome-sx :chrome chrome-sx
:feature-image feature-image :feature-image feature-image
:html-content html-content :html-content html-content
:sx-content sx-content))) :sx-content sx-content)))
(defcomp ~blog-meta (&key (robots :as string) (page-title :as string) (desc :as string) (canonical :as string?) (og-type :as string) (og-title :as string) (image :as string?) (twitter-card :as string) (twitter-title :as string)) (defcomp ~detail/meta (&key (robots :as string) (page-title :as string) (desc :as string) (canonical :as string?) (og-type :as string) (og-title :as string) (image :as string?) (twitter-card :as string) (twitter-title :as string))
(<> (<>
(meta :name "robots" :content robots) (meta :name "robots" :content robots)
(title page-title) (title page-title)
@@ -86,7 +86,7 @@
(meta :name "twitter:description" :content desc) (meta :name "twitter:description" :content desc)
(when image (meta :name "twitter:image" :content image)))) (when image (meta :name "twitter:image" :content image))))
(defcomp ~blog-home-main (&key html-content sx-content) (defcomp ~detail/home-main (&key html-content sx-content)
(article :class "relative" (article :class "relative"
(if sx-content (if sx-content
(div :class "blog-content p-2" sx-content) (div :class "blog-content p-2" sx-content)

View File

@@ -1,10 +1,10 @@
;; Blog editor components ;; Blog editor components
(defcomp ~blog-editor-error (&key error) (defcomp ~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" (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)) (strong "Save failed:") " " error))
(defcomp ~blog-editor-form (&key (csrf :as string) (title-placeholder :as string) (create-label :as string)) (defcomp ~editor/form (&key (csrf :as string) (title-placeholder :as string) (create-label :as string))
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]" (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" :name "csrf_token" :value csrf)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "") (input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
@@ -56,7 +56,7 @@
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label)))) :class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
;; Edit form — pre-populated version for /<slug>/admin/edit/ ;; Edit form — pre-populated version for /<slug>/admin/edit/
(defcomp ~blog-editor-edit-form (&key (csrf :as string) (updated-at :as string) (title-val :as string?) (excerpt-val :as string?) (defcomp ~editor/edit-form (&key (csrf :as string) (updated-at :as string) (title-val :as string?) (excerpt-val :as string?)
(feature-image :as string?) (feature-image-caption :as string?) (feature-image :as string?) (feature-image-caption :as string?)
(sx-content-val :as string?) (lexical-json :as string?) (sx-content-val :as string?) (lexical-json :as string?)
(has-sx :as boolean) (title-placeholder :as string) (has-sx :as boolean) (title-placeholder :as string)
@@ -135,7 +135,7 @@
(when footer-extra footer-extra))))) (when footer-extra footer-extra)))))
;; Publish-mode show/hide script for edit form ;; Publish-mode show/hide script for edit form
(defcomp ~blog-editor-publish-js (&key already-emailed) (defcomp ~editor/publish-js (&key already-emailed)
(script (script
"(function() {" "(function() {"
" var statusSel = document.getElementById('status-select');" " var statusSel = document.getElementById('status-select');"
@@ -153,20 +153,20 @@
" sync();" " sync();"
"})();")) "})();"))
(defcomp ~blog-editor-styles (&key (css-href :as string)) (defcomp ~editor/styles (&key (css-href :as string))
(<> (link :rel "stylesheet" :href css-href) (<> (link :rel "stylesheet" :href css-href)
(style (style
"#lexical-editor { display: flow-root; }" "#lexical-editor { display: flow-root; }"
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }" "#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }"))) "#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
(defcomp ~blog-editor-scripts (&key (js-src :as string) (sx-editor-js-src :as string?) (init-js :as string)) (defcomp ~editor/scripts (&key (js-src :as string) (sx-editor-js-src :as string?) (init-js :as string))
(<> (script :src js-src) (<> (script :src js-src)
(when sx-editor-js-src (script :src sx-editor-js-src)) (when sx-editor-js-src (script :src sx-editor-js-src))
(script init-js))) (script init-js)))
;; SX editor styles — comprehensive CSS for the Koenig-style block editor ;; SX editor styles — comprehensive CSS for the Koenig-style block editor
(defcomp ~sx-editor-styles () (defcomp ~editor/sx-editor-styles ()
(style (style
;; Editor container ;; Editor container
".sx-editor { position: relative; font-size: 18px; line-height: 1.6; font-family: Georgia, 'Times New Roman', serif; color: #1c1917; }" ".sx-editor { position: relative; font-size: 18px; line-height: 1.6; font-family: Georgia, 'Times New Roman', serif; color: #1c1917; }"
@@ -308,34 +308,34 @@
;; Editor panel composition — replaces render_editor_panel (new post/page) ;; Editor panel composition — replaces render_editor_panel (new post/page)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-editor-content (&key csrf title-placeholder create-label (defcomp ~editor/content (&key csrf title-placeholder create-label
css-href js-src sx-editor-js-src init-js css-href js-src sx-editor-js-src init-js
save-error) save-error)
(~blog-editor-panel :parts (~layouts/editor-panel :parts
(<> (<>
(when save-error (~blog-editor-error :error save-error)) (when save-error (~editor/error :error save-error))
(~blog-editor-form :csrf csrf :title-placeholder title-placeholder (~editor/form :csrf csrf :title-placeholder title-placeholder
:create-label create-label) :create-label create-label)
(~blog-editor-styles :css-href css-href) (~editor/styles :css-href css-href)
(~sx-editor-styles) (~editor/sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src (~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js)))) :init-js init-js))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Edit content composition — replaces _h_post_edit_content (existing post) ;; Edit content composition — replaces _h_post_edit_content (existing post)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-edit-content (&key csrf updated-at title-val excerpt-val (defcomp ~editor/edit-content (&key csrf updated-at title-val excerpt-val
feature-image feature-image-caption feature-image feature-image-caption
sx-content-val lexical-json has-sx sx-content-val lexical-json has-sx
title-placeholder status already-emailed title-placeholder status already-emailed
newsletter-options footer-extra newsletter-options footer-extra
css-href js-src sx-editor-js-src init-js css-href js-src sx-editor-js-src init-js
save-error) save-error)
(~blog-editor-panel :parts (~layouts/editor-panel :parts
(<> (<>
(when save-error (~blog-editor-error :error save-error)) (when save-error (~editor/error :error save-error))
(~blog-editor-edit-form (~editor/edit-form
:csrf csrf :updated-at updated-at :csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val :title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption :feature-image feature-image :feature-image-caption feature-image-caption
@@ -343,8 +343,8 @@
:has-sx has-sx :title-placeholder title-placeholder :has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed :status status :already-emailed already-emailed
:newsletter-options newsletter-options :footer-extra footer-extra) :newsletter-options newsletter-options :footer-extra footer-extra)
(~blog-editor-publish-js :already-emailed already-emailed) (~editor/publish-js :already-emailed already-emailed)
(~blog-editor-styles :css-href css-href) (~editor/styles :css-href css-href)
(~sx-editor-styles) (~editor/sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src (~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js)))) :init-js init-js))))

View File

@@ -1,37 +1,37 @@
;; Blog filter components ;; Blog filter components
(defcomp ~blog-action-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (icon-class :as string) (label :as string)) (defcomp ~filters/action-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (icon-class :as string) (label :as string))
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class icon-class) label)) :class btn-class :title title (i :class icon-class) label))
(defcomp ~blog-drafts-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (label :as string) (draft-count :as number)) (defcomp ~filters/drafts-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (label :as string) (draft-count :as number))
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts " :class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count))) (span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count) (defcomp ~filters/drafts-button-amber (&key href hx-select btn-class title label draft-count)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts " :class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count))) (span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-action-buttons-wrapper (&key inner) (defcomp ~filters/action-buttons-wrapper (&key inner)
(div :class "flex flex-wrap gap-2 px-4 py-3" inner)) (div :class "flex flex-wrap gap-2 px-4 py-3" inner))
(defcomp ~blog-filter-any-topic (&key cls hx-select) (defcomp ~filters/any-topic (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded border " cls) (li (a :class (str "px-3 py-1 rounded border " cls)
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select :sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" "Any Topic"))) :sx-swap "outerHTML" :sx-push-url "true" "Any Topic")))
(defcomp ~blog-filter-group-icon-image (&key src name) (defcomp ~filters/group-icon-image (&key src name)
(img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0")) (img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-filter-group-icon-color (&key style initial) (defcomp ~filters/group-icon-color (&key style initial)
(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial)) (div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial))
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon name count) (defcomp ~filters/group-li (&key cls hx-get hx-select icon name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls) (li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls)
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select :sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :sx-swap "outerHTML" :sx-push-url "true"
@@ -40,19 +40,19 @@
(span :class "flex-1") (span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count)))) (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
(defcomp ~blog-filter-nav (&key items) (defcomp ~filters/nav (&key items)
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm" (nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
(ul :class "divide-y flex flex-col gap-3" items))) (ul :class "divide-y flex flex-col gap-3" items)))
(defcomp ~blog-filter-any-author (&key cls hx-select) (defcomp ~filters/any-author (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded " cls) (li (a :class (str "px-3 py-1 rounded " cls)
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select :sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" "Any author"))) :sx-swap "outerHTML" :sx-push-url "true" "Any author")))
(defcomp ~blog-filter-author-icon (&key src name) (defcomp ~filters/author-icon (&key src name)
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover")) (img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon name count) (defcomp ~filters/author-li (&key cls hx-get hx-select icon name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls) (li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls)
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select :sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :sx-swap "outerHTML" :sx-push-url "true"
@@ -61,41 +61,41 @@
(span :class "flex-1") (span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count)))) (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
(defcomp ~blog-filter-summary (&key (text :as string)) (defcomp ~filters/summary (&key (text :as string))
(span :class "text-sm text-stone-600" text)) (span :class "text-sm text-stone-600" text))
;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop) ;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop)
(defcomp ~blog-tag-groups-filter-from-data (&key groups selected-groups hx-select) (defcomp ~filters/tag-groups-filter-from-data (&key groups selected-groups hx-select)
(let* ((is-any (empty? (or selected-groups (list)))) (let* ((is-any (empty? (or selected-groups (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))) (any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav (~filters/nav
:items (<> :items (<>
(~blog-filter-any-topic :cls any-cls :hx-select hx-select) (~filters/any-topic :cls any-cls :hx-select hx-select)
(map (lambda (g) (map (lambda (g)
(let* ((slug (get g "slug")) (let* ((slug (get g "slug"))
(name (get g "name")) (name (get g "name"))
(is-on (contains? selected-groups slug)) (is-on (contains? selected-groups slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")) (cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (if (get g "feature_image") (icon (if (get g "feature_image")
(~blog-filter-group-icon-image :src (get g "feature_image") :name name) (~filters/group-icon-image :src (get g "feature_image") :name name)
(~blog-filter-group-icon-color :style (get g "style") :initial (get g "initial"))))) (~filters/group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-filter-group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select (~filters/group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
:icon icon :name name :count (get g "count")))) :icon icon :name name :count (get g "count"))))
(or groups (list))))))) (or groups (list)))))))
;; Data-driven authors filter (replaces Python _authors_filter_sx loop) ;; Data-driven authors filter (replaces Python _authors_filter_sx loop)
(defcomp ~blog-authors-filter-from-data (&key authors selected-authors hx-select) (defcomp ~filters/authors-filter-from-data (&key authors selected-authors hx-select)
(let* ((is-any (empty? (or selected-authors (list)))) (let* ((is-any (empty? (or selected-authors (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))) (any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav (~filters/nav
:items (<> :items (<>
(~blog-filter-any-author :cls any-cls :hx-select hx-select) (~filters/any-author :cls any-cls :hx-select hx-select)
(map (lambda (a) (map (lambda (a)
(let* ((slug (get a "slug")) (let* ((slug (get a "slug"))
(is-on (contains? selected-authors slug)) (is-on (contains? selected-authors slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")) (cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (when (get a "profile_image") (icon (when (get a "profile_image")
(~blog-filter-author-icon :src (get a "profile_image") :name (get a "name"))))) (~filters/author-icon :src (get a "profile_image") :name (get a "name")))))
(~blog-filter-author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select (~filters/author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
:icon icon :name (get a "name") :count (get a "count")))) :icon icon :name (get a "name") :count (get a "count"))))
(or authors (list))))))) (or authors (list)))))))

View File

@@ -11,7 +11,7 @@
(let ((post (query "blog" "post-by-slug" :slug (trim s)))) (let ((post (query "blog" "post-by-slug" :slug (trim s))))
(when post (when post
(<> (str "<!-- fragment:" (trim s) " -->") (<> (str "<!-- fragment:" (trim s) " -->")
(~link-card (~shared:fragments/link-card
:link (app-url "blog" (str "/" (get post "slug") "/")) :link (app-url "blog" (str "/" (get post "slug") "/"))
:title (get post "title") :title (get post "title")
:image (get post "feature_image") :image (get post "feature_image")
@@ -22,7 +22,7 @@
(when slug (when slug
(let ((post (query "blog" "post-by-slug" :slug slug))) (let ((post (query "blog" "post-by-slug" :slug slug)))
(when post (when post
(~link-card (~shared:fragments/link-card
:link (app-url "blog" (str "/" (get post "slug") "/")) :link (app-url "blog" (str "/" (get post "slug") "/"))
:title (get post "title") :title (get post "title")
:image (get post "feature_image") :image (get post "feature_image")

View File

@@ -30,25 +30,25 @@
(app-url "blog" (str "/" item-slug "/")))) (app-url "blog" (str "/" item-slug "/"))))
(selected (or (= item-slug (or first-seg "")) (selected (or (= item-slug (or first-seg ""))
(= item-slug app)))) (= item-slug app))))
(~blog-nav-item-link (~shared:nav/blog-nav-item-link
:href href :href href
:hx-get href :hx-get href
:selected (if selected "true" "false") :selected (if selected "true" "false")
:nav-cls nav-cls :nav-cls nav-cls
:img (~img-or-placeholder :img (~shared:misc/img-or-placeholder
:src (get item "feature_image") :src (get item "feature_image")
:alt (or (get item "label") item-slug) :alt (or (get item "label") item-slug)
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0") :size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
:label (or (get item "label") item-slug)))) items) :label (or (get item "label") item-slug)))) items)
;; Hardcoded artdag link ;; Hardcoded artdag link
(~blog-nav-item-link (~shared:nav/blog-nav-item-link
:href (app-url "artdag" "/") :href (app-url "artdag" "/")
:hx-get (app-url "artdag" "/") :hx-get (app-url "artdag" "/")
:selected (if (or (= "artdag" (or first-seg "")) :selected (if (or (= "artdag" (or first-seg ""))
(= "artdag" app)) "true" "false") (= "artdag" app)) "true" "false")
:nav-cls nav-cls :nav-cls nav-cls
:img (~img-or-placeholder :img (~shared:misc/img-or-placeholder
:src nil :alt "art-dag" :src nil :alt "art-dag"
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0") :size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
:label "art-dag"))) :label "art-dag")))
@@ -69,8 +69,8 @@
(right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200"))) (right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200")))
(if (empty? items) (if (empty? items)
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper") (~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~scroll-nav-wrapper (~shared:misc/scroll-nav-wrapper
:wrapper-id "menu-items-nav-wrapper" :wrapper-id "menu-items-nav-wrapper"
:container-id cid :container-id cid
:arrow-cls arrow-cls :arrow-cls arrow-cls

View File

@@ -1,21 +1,21 @@
;; Blog header components ;; Blog header components
(defcomp ~blog-container-nav (&key container-nav) (defcomp ~header/container-nav (&key container-nav)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" container-nav)) :id "entries-calendars-nav-wrapper" container-nav))
(defcomp ~blog-admin-label () (defcomp ~header/admin-label ()
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")) (<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label is-selected select-colours) (defcomp ~header/admin-nav-item (&key href nav-btn-class label is-selected select-colours)
(div :class "relative nav-group" (div :class "relative nav-group"
(a :href href (a :href href
:aria-selected (when is-selected "true") :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 "")) :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))) label)))
(defcomp ~blog-sub-settings-label (&key icon label) (defcomp ~header/sub-settings-label (&key icon label)
(<> (i :class icon :aria-hidden "true") " " label)) (<> (i :class icon :aria-hidden "true") " " label))
(defcomp ~blog-sub-admin-label (&key icon label) (defcomp ~header/sub-admin-label (&key icon label)
(<> (i :class icon :aria-hidden "true") (div label))) (<> (i :class icon :aria-hidden "true") (div label)))

View File

@@ -1,9 +1,9 @@
;; Blog index components ;; Blog index components
(defcomp ~blog-no-pages () (defcomp ~index/no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found.")) (div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls) (defcomp ~index/content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(div :class "flex justify-center gap-1 px-3 pt-3" (div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :sx-get posts-href :sx-target "#main-panel" (a :href posts-href :sx-get posts-href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
@@ -12,18 +12,18 @@
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages"))) :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
(defcomp ~blog-main-panel-pages (&key tabs cards) (defcomp ~index/main-panel-pages (&key tabs cards)
(<> tabs (<> tabs
(div :class "max-w-full px-3 py-3 space-y-3" cards) (div :class "max-w-full px-3 py-3 space-y-3" cards)
(div :class "pb-8"))) (div :class "pb-8")))
(defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards) (defcomp ~index/main-panel-posts (&key tabs toggle grid-cls cards)
(<> tabs (<> tabs
toggle toggle
(div :class grid-cls cards) (div :class grid-cls cards)
(div :class "pb-8"))) (div :class "pb-8")))
(defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter) (defcomp ~index/aside (&key search action-buttons tag-groups-filter authors-filter)
(<> search (<> search
action-buttons action-buttons
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" (div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
@@ -36,12 +36,12 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Helper: CSS class for filter item based on selection state ;; Helper: CSS class for filter item based on selection state
(defcomp ~blog-filter-cls (&key is-on) (defcomp ~index/filter-cls (&key is-on)
;; Returns nothing — use inline (if is-on ...) instead ;; Returns nothing — use inline (if is-on ...) instead
nil) nil)
;; Blog index main content — replaces _blog_main_panel_sx ;; Blog index main content — replaces _blog_main_panel_sx
(defcomp ~blog-index-main-content (&key content-type view cards page total-pages (defcomp ~index/main-content (&key content-type view cards page total-pages
current-local-href hx-select blog-url-base) current-local-href hx-select blog-url-base)
(let* ((posts-href (str blog-url-base "/index")) (let* ((posts-href (str blog-url-base "/index"))
(pages-href (str posts-href "?type=pages")) (pages-href (str posts-href "?type=pages"))
@@ -51,13 +51,13 @@
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200"))) "bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")))
(if (= content-type "pages") (if (= content-type "pages")
;; Pages listing ;; Pages listing
(~blog-main-panel-pages (~index/main-panel-pages
:tabs (~blog-content-type-tabs :tabs (~index/content-type-tabs
:posts-href posts-href :pages-href pages-href :posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls) :hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:cards (<> :cards (<>
(map (lambda (card) (map (lambda (card)
(~blog-page-card (~cards/page-card
:href (get card "href") :hx-select hx-select :href (get card "href") :hx-select hx-select
:title (get card "title") :title (get card "title")
:has-calendar (get card "has_calendar") :has-calendar (get card "has_calendar")
@@ -67,14 +67,14 @@
:excerpt (get card "excerpt"))) :excerpt (get card "excerpt")))
(or cards (list))) (or cards (list)))
(if (< page total-pages) (if (< page total-pages)
(~sentinel-simple (~shared:misc/sentinel-simple
:id (str "sentinel-" page "-d") :id (str "sentinel-" page "-d")
:next-url (str current-local-href :next-url (str current-local-href
(if (contains? current-local-href "?") "&" "?") (if (contains? current-local-href "?") "&" "?")
"page=" (+ page 1))) "page=" (+ page 1)))
(if (not (empty? (or cards (list)))) (if (not (empty? (or cards (list))))
(~end-of-results) (~shared:misc/end-of-results)
(~blog-no-pages))))) (~index/no-pages)))))
;; Posts listing ;; Posts listing
(let* ((grid-cls (if (= view "tile") (let* ((grid-cls (if (= view "tile")
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
@@ -88,19 +88,19 @@
(tile-cls (if (= view "tile") (tile-cls (if (= view "tile")
"bg-stone-200 text-stone-800" "bg-stone-200 text-stone-800"
"text-stone-400 hover:text-stone-600"))) "text-stone-400 hover:text-stone-600")))
(~blog-main-panel-posts (~index/main-panel-posts
:tabs (~blog-content-type-tabs :tabs (~index/content-type-tabs
:posts-href posts-href :pages-href pages-href :posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls) :hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:toggle (~view-toggle :toggle (~shared:misc/view-toggle
:list-href list-href :tile-href tile-href :hx-select hx-select :list-href list-href :tile-href tile-href :hx-select hx-select
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view" :list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
:list-svg (~list-svg) :tile-svg (~tile-svg)) :list-svg (~shared:misc/list-svg) :tile-svg (~shared:misc/tile-svg))
:grid-cls grid-cls :grid-cls grid-cls
:cards (<> :cards (<>
(map (lambda (card) (map (lambda (card)
(if (= view "tile") (if (= view "tile")
(~blog-card-tile (~cards/tile
:href (get card "href") :hx-select hx-select :href (get card "href") :hx-select hx-select
:feature-image (get card "feature_image") :feature-image (get card "feature_image")
:title (get card "title") :is-draft (get card "is_draft") :title (get card "title") :is-draft (get card "is_draft")
@@ -108,7 +108,7 @@
:status-timestamp (get card "status_timestamp") :status-timestamp (get card "status_timestamp")
:excerpt (get card "excerpt") :excerpt (get card "excerpt")
:tags (get card "tags") :authors (get card "authors")) :tags (get card "tags") :authors (get card "authors"))
(~blog-card (~cards/index
:slug (get card "slug") :href (get card "href") :hx-select hx-select :slug (get card "slug") :href (get card "href") :hx-select hx-select
:title (get card "title") :feature-image (get card "feature_image") :title (get card "title") :feature-image (get card "feature_image")
:excerpt (get card "excerpt") :is-draft (get card "is_draft") :excerpt (get card "excerpt") :is-draft (get card "is_draft")
@@ -119,52 +119,52 @@
:tags (get card "tags") :authors (get card "authors") :tags (get card "tags") :authors (get card "authors")
:widget (get card "widget")))) :widget (get card "widget"))))
(or cards (list))) (or cards (list)))
(~blog-index-sentinel (~index/sentinel
:page page :total-pages total-pages :page page :total-pages total-pages
:current-local-href current-local-href))))))) :current-local-href current-local-href)))))))
;; Sentinel for blog index infinite scroll ;; Sentinel for blog index infinite scroll
(defcomp ~blog-index-sentinel (&key page total-pages current-local-href) (defcomp ~index/sentinel (&key page total-pages current-local-href)
(when (< page total-pages) (when (< page total-pages)
(let* ((next-url (str current-local-href "?page=" (+ page 1)))) (let* ((next-url (str current-local-href "?page=" (+ page 1))))
(~sentinel-desktop (~shared:misc/sentinel-desktop
:id (str "sentinel-" page "-d") :id (str "sentinel-" page "-d")
:next-url next-url :next-url next-url
:hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()")))) :hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"))))
;; Blog index action buttons — replaces _action_buttons_sx ;; Blog index action buttons — replaces _action_buttons_sx
(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts (defcomp ~index/actions (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href) new-post-href new-page-href current-local-href)
(~blog-action-buttons-wrapper (~filters/action-buttons-wrapper
:inner (<> :inner (<>
(when is-admin (when is-admin
(<> (<>
(~blog-action-button (~filters/action-button
:href new-post-href :hx-select hx-select :href new-post-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors" :btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post") :title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
(~blog-action-button (~filters/action-button
:href new-page-href :hx-select hx-select :href new-page-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors" :btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page"))) :title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
(when (and has-user (or draft-count drafts)) (when (and has-user (or draft-count drafts))
(if drafts (if drafts
(~blog-drafts-button (~filters/drafts-button
:href current-local-href :hx-select hx-select :href current-local-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors" :btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count)) :title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
(let* ((on-href (str current-local-href (let* ((on-href (str current-local-href
(if (contains? current-local-href "?") "&" "?") "drafts=1"))) (if (contains? current-local-href "?") "&" "?") "drafts=1")))
(~blog-drafts-button-amber (~filters/drafts-button-amber
:href on-href :hx-select hx-select :href on-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors" :btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count)))))))) :title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
;; Tag groups filter — replaces _tag_groups_filter_sx ;; Tag groups filter — replaces _tag_groups_filter_sx
(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select) (defcomp ~index/tag-groups-filter (&key tag-groups is-any-group hx-select)
(~blog-filter-nav (~filters/nav
:items (<> :items (<>
(~blog-filter-any-topic (~filters/any-topic
:cls (if is-any-group :cls (if is-any-group
"bg-stone-900 text-white border-stone-900" "bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50") "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
@@ -178,23 +178,23 @@
(colour (get grp "colour")) (colour (get grp "colour"))
(name (get grp "name")) (name (get grp "name"))
(icon (if fi (icon (if fi
(~blog-filter-group-icon-image :src fi :name name) (~filters/group-icon-image :src fi :name name)
(~blog-filter-group-icon-color (~filters/group-icon-color
:style (if colour :style (if colour
(str "background-color: " colour "; color: white;") (str "background-color: " colour "; color: white;")
"background-color: #e7e5e4; color: #57534e;") "background-color: #e7e5e4; color: #57534e;")
:initial (slice (or name "?") 0 1))))) :initial (slice (or name "?") 0 1)))))
(~blog-filter-group-li (~filters/group-li
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1") :cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
:hx-select hx-select :icon icon :hx-select hx-select :icon icon
:name name :count (str (get grp "post_count"))))) :name name :count (str (get grp "post_count")))))
(or tag-groups (list)))))) (or tag-groups (list))))))
;; Authors filter — replaces _authors_filter_sx ;; Authors filter — replaces _authors_filter_sx
(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select) (defcomp ~index/authors-filter (&key authors is-any-author hx-select)
(~blog-filter-nav (~filters/nav
:items (<> :items (<>
(~blog-filter-any-author (~filters/any-author
:cls (if is-any-author :cls (if is-any-author
"bg-stone-900 text-white border-stone-900" "bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50") "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
@@ -205,49 +205,49 @@
"bg-stone-900 text-white border-stone-900" "bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")) "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(img (get a "profile_image"))) (img (get a "profile_image")))
(~blog-filter-author-li (~filters/author-li
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1") :cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
:hx-select hx-select :hx-select hx-select
:icon (when img (~blog-filter-author-icon :src img :name (get a "name"))) :icon (when img (~filters/author-icon :src img :name (get a "name")))
:name (get a "name") :name (get a "name")
:count (str (get a "published_post_count"))))) :count (str (get a "published_post_count")))))
(or authors (list)))))) (or authors (list))))))
;; Blog index aside — replaces _blog_aside_sx ;; Blog index aside — replaces _blog_aside_sx
(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts (defcomp ~index/aside-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author) tag-groups authors is-any-group is-any-author)
(~blog-aside (~index/aside
:search (~search-desktop) :search (~shared:controls/search-desktop)
:action-buttons (~blog-index-actions :action-buttons (~index/actions
:is-admin is-admin :has-user has-user :hx-select hx-select :is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts :draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href :new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href) :current-local-href current-local-href)
:tag-groups-filter (~blog-index-tag-groups-filter :tag-groups-filter (~index/tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select) :tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
:authors-filter (~blog-index-authors-filter :authors-filter (~index/authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select))) :authors authors :is-any-author is-any-author :hx-select hx-select)))
;; Blog index mobile filter — replaces _blog_filter_sx ;; Blog index mobile filter — replaces _blog_filter_sx
(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts (defcomp ~index/filter-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author tag-groups authors is-any-group is-any-author
tg-summary au-summary) tg-summary au-summary)
(~mobile-filter (~shared:controls/mobile-filter
:filter-summary (<> :filter-summary (<>
(~search-mobile) (~shared:controls/search-mobile)
(when (not (= tg-summary "")) (when (not (= tg-summary ""))
(~blog-filter-summary :text tg-summary)) (~filters/summary :text tg-summary))
(when (not (= au-summary "")) (when (not (= au-summary ""))
(~blog-filter-summary :text au-summary))) (~filters/summary :text au-summary)))
:action-buttons (~blog-index-actions :action-buttons (~index/actions
:is-admin is-admin :has-user has-user :hx-select hx-select :is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts :draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href :new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href) :current-local-href current-local-href)
:filter-details (<> :filter-details (<>
(~blog-index-tag-groups-filter (~index/tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select) :tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
(~blog-index-authors-filter (~index/authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select)))) :authors authors :is-any-author is-any-author :hx-select hx-select))))

View File

@@ -7,7 +7,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Image card ;; Image card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-image (&key (src :as string) (alt :as string?) (caption :as string?) (width :as string?) (href :as string?)) (defcomp ~kg_cards/kg-image (&key (src :as string) (alt :as string?) (caption :as string?) (width :as string?) (href :as string?))
(figure :class (str "kg-card kg-image-card" (figure :class (str "kg-card kg-image-card"
(if (= width "wide") " kg-width-wide" (if (= width "wide") " kg-width-wide"
(if (= width "full") " kg-width-full" ""))) (if (= width "full") " kg-width-full" "")))
@@ -19,7 +19,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Gallery card ;; Gallery card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-gallery (&key (images :as list) (caption :as string?)) (defcomp ~kg_cards/kg-gallery (&key (images :as list) (caption :as string?))
(figure :class "kg-card kg-gallery-card kg-width-wide" (figure :class "kg-card kg-gallery-card kg-width-wide"
(div :class "kg-gallery-container" (div :class "kg-gallery-container"
(map (lambda (row) (map (lambda (row)
@@ -36,19 +36,19 @@
;; HTML card — wraps user-pasted HTML so the editor can identify the block. ;; HTML card — wraps user-pasted HTML so the editor can identify the block.
;; Content is native sx children (no longer an opaque HTML string). ;; Content is native sx children (no longer an opaque HTML string).
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-html (&rest children) (defcomp ~kg_cards/kg-html (&rest children)
(div :class "kg-card kg-html-card" children)) (div :class "kg-card kg-html-card" children))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Markdown card — rendered markdown content, editor can identify the block. ;; Markdown card — rendered markdown content, editor can identify the block.
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-md (&rest children) (defcomp ~kg_cards/kg-md (&rest children)
(div :class "kg-card kg-md-card" children)) (div :class "kg-card kg-md-card" children))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Embed card ;; Embed card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-embed (&key (html :as string) (caption :as string?)) (defcomp ~kg_cards/kg-embed (&key (html :as string) (caption :as string?))
(figure :class "kg-card kg-embed-card" (figure :class "kg-card kg-embed-card"
(~rich-text :html html) (~rich-text :html html)
(when caption (figcaption caption)))) (when caption (figcaption caption))))
@@ -56,7 +56,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Bookmark card ;; Bookmark card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-bookmark (&key (url :as string) (title :as string?) (description :as string?) (icon :as string?) (author :as string?) (publisher :as string?) (thumbnail :as string?) (caption :as string?)) (defcomp ~kg_cards/kg-bookmark (&key (url :as string) (title :as string?) (description :as string?) (icon :as string?) (author :as string?) (publisher :as string?) (thumbnail :as string?) (caption :as string?))
(figure :class "kg-card kg-bookmark-card" (figure :class "kg-card kg-bookmark-card"
(a :class "kg-bookmark-container" :href url (a :class "kg-bookmark-container" :href url
(div :class "kg-bookmark-content" (div :class "kg-bookmark-content"
@@ -75,7 +75,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Callout card ;; Callout card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-callout (&key (color :as string?) (emoji :as string?) (content :as string?)) (defcomp ~kg_cards/kg-callout (&key (color :as string?) (emoji :as string?) (content :as string?))
(div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey")) (div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey"))
(when emoji (div :class "kg-callout-emoji" emoji)) (when emoji (div :class "kg-callout-emoji" emoji))
(div :class "kg-callout-text" (or content "")))) (div :class "kg-callout-text" (or content ""))))
@@ -83,14 +83,14 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Button card ;; Button card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-button (&key (url :as string) (text :as string?) (alignment :as string?)) (defcomp ~kg_cards/kg-button (&key (url :as string) (text :as string?) (alignment :as string?))
(div :class (str "kg-card kg-button-card kg-align-" (or alignment "center")) (div :class (str "kg-card kg-button-card kg-align-" (or alignment "center"))
(a :href url :class "kg-btn kg-btn-accent" (or text "")))) (a :href url :class "kg-btn kg-btn-accent" (or text ""))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Toggle card (accordion) ;; Toggle card (accordion)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-toggle (&key (heading :as string?) (content :as string?)) (defcomp ~kg_cards/kg-toggle (&key (heading :as string?) (content :as string?))
(div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close" (div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close"
(div :class "kg-toggle-heading" (div :class "kg-toggle-heading"
(h4 :class "kg-toggle-heading-text" (or heading "")) (h4 :class "kg-toggle-heading-text" (or heading ""))
@@ -101,7 +101,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Audio card ;; Audio card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-audio (&key (src :as string) (title :as string?) (duration :as string?) (thumbnail :as string?)) (defcomp ~kg_cards/kg-audio (&key (src :as string) (title :as string?) (duration :as string?) (thumbnail :as string?))
(div :class "kg-card kg-audio-card" (div :class "kg-card kg-audio-card"
(if thumbnail (if thumbnail
(img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail") (img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
@@ -124,7 +124,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Video card ;; Video card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-video (&key (src :as string) (caption :as string?) (width :as string?) (thumbnail :as string?) (loop :as boolean?)) (defcomp ~kg_cards/kg-video (&key (src :as string) (caption :as string?) (width :as string?) (thumbnail :as string?) (loop :as boolean?))
(figure :class (str "kg-card kg-video-card" (figure :class (str "kg-card kg-video-card"
(if (= width "wide") " kg-width-wide" (if (= width "wide") " kg-width-wide"
(if (= width "full") " kg-width-full" ""))) (if (= width "full") " kg-width-full" "")))
@@ -136,7 +136,7 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; File card ;; File card
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-file (&key (src :as string) (filename :as string?) (title :as string?) (filesize :as string?) (caption :as string?)) (defcomp ~kg_cards/kg-file (&key (src :as string) (filename :as string?) (title :as string?) (filesize :as string?) (caption :as string?))
(div :class "kg-card kg-file-card" (div :class "kg-card kg-file-card"
(a :class "kg-file-card-container" :href src :download (or filename "") (a :class "kg-file-card-container" :href src :download (or filename "")
(div :class "kg-file-card-contents" (div :class "kg-file-card-contents"
@@ -149,5 +149,5 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Paywall marker ;; Paywall marker
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~kg-paywall () (defcomp ~kg_cards/kg-paywall ()
(~rich-text :html "<!--members-only-->")) (~rich-text :html "<!--members-only-->"))

View File

@@ -3,8 +3,8 @@
;; --- Blog header (invisible row for blog-header-child swap target) --- ;; --- Blog header (invisible row for blog-header-child swap target) ---
(defcomp ~blog-header (&key oob) (defcomp ~layouts/header (&key oob)
(~menu-row-sx :id "blog-row" :level 1 (~shared:layout/menu-row-sx :id "blog-row" :level 1
:link-label-content (div) :link-label-content (div)
:child-id "blog-header-child" :oob oob)) :child-id "blog-header-child" :oob oob))
@@ -12,10 +12,10 @@
(defmacro ~blog-settings-header-auto (oob) (defmacro ~blog-settings-header-auto (oob)
(quasiquote (quasiquote
(~menu-row-sx :id "root-settings-row" :level 1 (~shared:layout/menu-row-sx :id "root-settings-row" :level 1
:link-href (url-for "settings.defpage_settings_home") :link-href (url-for "settings.defpage_settings_home")
:link-label-content (~blog-admin-label) :link-label-content (~header/admin-label)
:nav (~blog-settings-nav) :nav (~layouts/settings-nav)
:child-id "root-settings-header-child" :child-id "root-settings-header-child"
:oob (unquote oob)))) :oob (unquote oob))))
@@ -23,9 +23,9 @@
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob) (defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
(quasiquote (quasiquote
(~menu-row-sx :id (unquote row-id) :level 2 (~shared:layout/menu-row-sx :id (unquote row-id) :level 2
:link-href (url-for (unquote endpoint)) :link-href (url-for (unquote endpoint))
:link-label-content (~blog-sub-settings-label :link-label-content (~header/sub-settings-label
:icon (str "fa fa-" (unquote icon)) :icon (str "fa fa-" (unquote icon))
:label (unquote label)) :label (unquote label))
:child-id (unquote child-id) :child-id (unquote child-id)
@@ -35,47 +35,47 @@
;; Blog layout (root + blog header) ;; Blog layout (root + blog header)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-layout-full () (defcomp ~layouts/full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~blog-header))) (~layouts/header)))
(defcomp ~blog-layout-oob () (defcomp ~layouts/oob ()
(<> (~blog-header :oob true) (<> (~layouts/header :oob true)
(~clear-oob-div :id "blog-header-child") (~shared:layout/clear-oob-div :id "blog-header-child")
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Settings layout (root + settings header) ;; Settings layout (root + settings header)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-layout-full () (defcomp ~layouts/settings-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~blog-settings-header-auto))) (~blog-settings-header-auto)))
(defcomp ~blog-settings-layout-oob () (defcomp ~layouts/settings-layout-oob ()
(<> (~blog-settings-header-auto true) (<> (~blog-settings-header-auto true)
(~clear-oob-div :id "root-settings-header-child") (~shared:layout/clear-oob-div :id "root-settings-header-child")
(~root-header-auto true))) (~root-header-auto true)))
(defcomp ~blog-settings-layout-mobile () (defcomp ~layouts/settings-layout-mobile ()
(~blog-settings-nav)) (~layouts/settings-nav))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Cache layout (root + settings + cache sub-header) ;; Cache layout (root + settings + cache sub-header)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-cache-layout-full () (defcomp ~layouts/cache-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~blog-settings-header-auto) (~blog-settings-header-auto)
(~blog-sub-settings-header-auto (~blog-sub-settings-header-auto
"cache-row" "cache-header-child" "cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache"))) "settings.defpage_cache_page" "refresh" "Cache")))
(defcomp ~blog-cache-layout-oob () (defcomp ~layouts/cache-layout-oob ()
(<> (~blog-sub-settings-header-auto (<> (~blog-sub-settings-header-auto
"cache-row" "cache-header-child" "cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache" true) "settings.defpage_cache_page" "refresh" "Cache" true)
(~clear-oob-div :id "cache-header-child") (~shared:layout/clear-oob-div :id "cache-header-child")
(~blog-settings-header-auto true) (~blog-settings-header-auto true)
(~root-header-auto true))) (~root-header-auto true)))
@@ -83,18 +83,18 @@
;; Snippets layout (root + settings + snippets sub-header) ;; Snippets layout (root + settings + snippets sub-header)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-snippets-layout-full () (defcomp ~layouts/snippets-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~blog-settings-header-auto) (~blog-settings-header-auto)
(~blog-sub-settings-header-auto (~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child" "snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets"))) "snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
(defcomp ~blog-snippets-layout-oob () (defcomp ~layouts/snippets-layout-oob ()
(<> (~blog-sub-settings-header-auto (<> (~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child" "snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true) "snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true)
(~clear-oob-div :id "snippets-header-child") (~shared:layout/clear-oob-div :id "snippets-header-child")
(~blog-settings-header-auto true) (~blog-settings-header-auto true)
(~root-header-auto true))) (~root-header-auto true)))
@@ -102,18 +102,18 @@
;; Menu Items layout (root + settings + menu-items sub-header) ;; Menu Items layout (root + settings + menu-items sub-header)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-menu-items-layout-full () (defcomp ~layouts/menu-items-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~blog-settings-header-auto) (~blog-settings-header-auto)
(~blog-sub-settings-header-auto (~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child" "menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items"))) "menu_items.defpage_menu_items_page" "bars" "Menu Items")))
(defcomp ~blog-menu-items-layout-oob () (defcomp ~layouts/menu-items-layout-oob ()
(<> (~blog-sub-settings-header-auto (<> (~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child" "menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items" true) "menu_items.defpage_menu_items_page" "bars" "Menu Items" true)
(~clear-oob-div :id "menu_items-header-child") (~shared:layout/clear-oob-div :id "menu_items-header-child")
(~blog-settings-header-auto true) (~blog-settings-header-auto true)
(~root-header-auto true))) (~root-header-auto true)))
@@ -121,18 +121,18 @@
;; Tag Groups layout (root + settings + tag-groups sub-header) ;; Tag Groups layout (root + settings + tag-groups sub-header)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-groups-layout-full () (defcomp ~layouts/tag-groups-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~blog-settings-header-auto) (~blog-settings-header-auto)
(~blog-sub-settings-header-auto (~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child" "tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups"))) "blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups")))
(defcomp ~blog-tag-groups-layout-oob () (defcomp ~layouts/tag-groups-layout-oob ()
(<> (~blog-sub-settings-header-auto (<> (~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child" "tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true) "blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true)
(~clear-oob-div :id "tag-groups-header-child") (~shared:layout/clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true) (~blog-settings-header-auto true)
(~root-header-auto true))) (~root-header-auto true)))
@@ -140,31 +140,31 @@
;; Tag Group Edit layout (root + settings + tag-groups sub-header with id) ;; Tag Group Edit layout (root + settings + tag-groups sub-header with id)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-group-edit-layout-full () (defcomp ~layouts/tag-group-edit-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~blog-settings-header-auto) (~blog-settings-header-auto)
(~menu-row-sx :id "tag-groups-row" :level 2 (~shared:layout/menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit" :link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id")) :id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label :link-label-content (~header/sub-settings-label
:icon "fa fa-tags" :label "Tag Groups") :icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child"))) :child-id "tag-groups-header-child")))
(defcomp ~blog-tag-group-edit-layout-oob () (defcomp ~layouts/tag-group-edit-layout-oob ()
(<> (~menu-row-sx :id "tag-groups-row" :level 2 (<> (~shared:layout/menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit" :link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id")) :id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label :link-label-content (~header/sub-settings-label
:icon "fa fa-tags" :label "Tag Groups") :icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child" :child-id "tag-groups-header-child"
:oob true) :oob true)
(~clear-oob-div :id "tag-groups-header-child") (~shared:layout/clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true) (~blog-settings-header-auto true)
(~root-header-auto true))) (~root-header-auto true)))
;; --- Settings nav links — uses IO primitives --- ;; --- Settings nav links — uses IO primitives ---
(defcomp ~blog-settings-nav () (defcomp ~layouts/settings-nav ()
(let* ((sc (select-colours)) (let* ((sc (select-colours))
(links (list (links (list
(dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items") (dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items")
@@ -172,7 +172,7 @@
(dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups") (dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups")
(dict :endpoint "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache")))) (dict :endpoint "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache"))))
(<> (map (lambda (lnk) (<> (map (lambda (lnk)
(~nav-link (~shared:layout/nav-link
:href (url-for (get lnk "endpoint")) :href (url-for (get lnk "endpoint"))
:icon (get lnk "icon") :icon (get lnk "icon")
:label (get lnk "label") :label (get lnk "label")
@@ -181,5 +181,5 @@
;; --- Editor panel wrapper --- ;; --- Editor panel wrapper ---
(defcomp ~blog-editor-panel (&key parts) (defcomp ~layouts/editor-panel (&key parts)
(<> parts)) (<> parts))

View File

@@ -1,6 +1,6 @@
;; Menu item form and page search components ;; Menu item form and page search components
(defcomp ~page-search-item (&key id title slug feature-image) (defcomp ~menu_items/page-search-item (&key id title slug feature-image)
(div :class "flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0" (div :class "flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
:data-page-id id :data-page-title title :data-page-slug slug :data-page-id id :data-page-title title :data-page-slug slug
:data-page-image (or feature-image "") :data-page-image (or feature-image "")
@@ -11,50 +11,50 @@
(div :class "font-medium truncate" title) (div :class "font-medium truncate" title)
(div :class "text-xs text-stone-500 truncate" slug)))) (div :class "text-xs text-stone-500 truncate" slug))))
(defcomp ~page-search-results (&key items sentinel) (defcomp ~menu_items/page-search-results (&key items sentinel)
(div :class "border border-stone-200 rounded-md max-h-64 overflow-y-auto" (div :class "border border-stone-200 rounded-md max-h-64 overflow-y-auto"
items sentinel)) items sentinel))
(defcomp ~page-search-sentinel (&key url query next-page) (defcomp ~menu_items/page-search-sentinel (&key url query next-page)
(div :sx-get url :sx-trigger "intersect once" :sx-swap "outerHTML" (div :sx-get url :sx-trigger "intersect once" :sx-swap "outerHTML"
:sx-vals (str "{\"q\": \"" query "\", \"page\": " next-page "}") :sx-vals (str "{\"q\": \"" query "\", \"page\": " next-page "}")
:class "p-3 text-center text-sm text-stone-400" :class "p-3 text-center text-sm text-stone-400"
(i :class "fa fa-spinner fa-spin") " Loading more...")) (i :class "fa fa-spinner fa-spin") " Loading more..."))
(defcomp ~page-search-empty (&key query) (defcomp ~menu_items/page-search-empty (&key query)
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md" (div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
(str "No pages found matching \"" query "\""))) (str "No pages found matching \"" query "\"")))
;; Data-driven page search results (replaces Python render_page_search_results loop) ;; Data-driven page search results (replaces Python render_page_search_results loop)
(defcomp ~page-search-results-from-data (&key pages query has-more search-url next-page) (defcomp ~menu_items/page-search-results-from-data (&key pages query has-more search-url next-page)
(if (and (not pages) query) (if (and (not pages) query)
(~page-search-empty :query query) (~menu_items/page-search-empty :query query)
(when pages (when pages
(~page-search-results (~menu_items/page-search-results
:items (<> (map (lambda (p) :items (<> (map (lambda (p)
(~page-search-item (~menu_items/page-search-item
:id (get p "id") :title (get p "title") :id (get p "id") :title (get p "title")
:slug (get p "slug") :feature-image (get p "feature_image"))) :slug (get p "slug") :feature-image (get p "feature_image")))
pages)) pages))
:sentinel (when has-more :sentinel (when has-more
(~page-search-sentinel :url search-url :query query :next-page next-page)))))) (~menu_items/page-search-sentinel :url search-url :query query :next-page next-page))))))
;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop) ;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop)
(defcomp ~blog-menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs) (defcomp ~menu_items/menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
(if (not items) (if (not items)
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper") (~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id (~shared:misc/scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
:arrow-cls arrow-cls :arrow-cls arrow-cls
:left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200") :left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
:scroll-hs scroll-hs :scroll-hs scroll-hs
:right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200") :right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
:items (<> (map (lambda (item) :items (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label") (let* ((img (~shared:misc/img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0"))) :size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")))
(if (= (get item "slug") "cart") (if (= (get item "slug") "cart")
(~blog-nav-item-plain :href (get item "href") :selected (get item "selected") (~shared:nav/blog-nav-item-plain :href (get item "href") :selected (get item "selected")
:nav-cls nav-cls :img img :label (get item "label")) :nav-cls nav-cls :img img :label (get item "label"))
(~blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get") (~shared:nav/blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
:selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label"))))) :selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label")))))
items)) items))
:oob true))) :oob true)))

View File

@@ -1,6 +1,6 @@
;; Blog settings panel components (features, markets, associated entries) ;; Blog settings panel components (features, markets, associated entries)
(defcomp ~blog-features-form (&key (features-url :as string) (calendar-checked :as boolean) (market-checked :as boolean) (hs-trigger :as string)) (defcomp ~settings/features-form (&key (features-url :as string) (calendar-checked :as boolean) (market-checked :as boolean) (hs-trigger :as string))
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML" (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" :sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
(label :class "flex items-center gap-3 cursor-pointer" (label :class "flex items-center gap-3 cursor-pointer"
@@ -18,33 +18,33 @@
(i :class "fa fa-shopping-bag text-green-600 mr-1") (i :class "fa fa-shopping-bag text-green-600 mr-1")
" Market \u2014 enable product catalog on this page")))) " Market \u2014 enable product catalog on this page"))))
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix) (defcomp ~settings/sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
(div :class "mt-4 pt-4 border-t border-stone-100" (div :class "mt-4 pt-4 border-t border-stone-100"
(~sumup-settings-form :update-url sumup-url :merchant-code merchant-code (~shared:misc/sumup-settings-form :update-url sumup-url :merchant-code merchant-code
:placeholder placeholder :sumup-configured sumup-configured :placeholder placeholder :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :panel-id "features-panel"))) :checkout-prefix checkout-prefix :panel-id "features-panel")))
(defcomp ~blog-features-panel (&key form sumup) (defcomp ~settings/features-panel (&key form sumup)
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200" (div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800" "Page Features") (h3 :class "text-lg font-semibold text-stone-800" "Page Features")
form sumup)) form sumup))
;; Markets panel ;; Markets panel
(defcomp ~blog-market-item (&key (name :as string) (slug :as string) (delete-url :as string) (confirm-text :as string)) (defcomp ~settings/market-item (&key (name :as string) (slug :as string) (delete-url :as string) (confirm-text :as string))
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded" (li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
(div (span :class "font-medium" name) (div (span :class "font-medium" name)
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/"))) (span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
(button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML" (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"))) :sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
(defcomp ~blog-markets-list (&key items) (defcomp ~settings/markets-list (&key items)
(ul :class "space-y-2 mb-4" items)) (ul :class "space-y-2 mb-4" items))
(defcomp ~blog-markets-empty () (defcomp ~settings/markets-empty ()
(p :class "text-stone-500 mb-4 text-sm" "No markets yet.")) (p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
(defcomp ~blog-markets-panel (&key list create-url) (defcomp ~settings/markets-panel (&key list create-url)
(div :id "markets-panel" (div :id "markets-panel"
(h3 :class "text-lg font-semibold mb-3" "Markets") (h3 :class "text-lg font-semibold mb-3" "Markets")
list list
@@ -59,17 +59,17 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Features panel composition — replaces render_features_panel ;; Features panel composition — replaces render_features_panel
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked (defcomp ~settings/features-panel-content (&key features-url calendar-checked market-checked
show-sumup sumup-url merchant-code placeholder show-sumup sumup-url merchant-code placeholder
sumup-configured checkout-prefix) sumup-configured checkout-prefix)
(~blog-features-panel (~settings/features-panel
:form (~blog-features-form :form (~settings/features-form
:features-url features-url :features-url features-url
:calendar-checked calendar-checked :calendar-checked calendar-checked
:market-checked market-checked :market-checked market-checked
:hs-trigger "on change trigger submit on closest <form/>") :hs-trigger "on change trigger submit on closest <form/>")
:sumup (when show-sumup :sumup (when show-sumup
(~blog-sumup-form (~settings/sumup-form
:sumup-url sumup-url :sumup-url sumup-url
:merchant-code merchant-code :merchant-code merchant-code
:placeholder placeholder :placeholder placeholder
@@ -77,13 +77,13 @@
:checkout-prefix checkout-prefix)))) :checkout-prefix checkout-prefix))))
;; Markets panel composition — replaces render_markets_panel ;; Markets panel composition — replaces render_markets_panel
(defcomp ~blog-markets-panel-content (&key markets create-url) (defcomp ~settings/markets-panel-content (&key markets create-url)
(~blog-markets-panel (~settings/markets-panel
:list (if (empty? (or markets (list))) :list (if (empty? (or markets (list)))
(~blog-markets-empty) (~settings/markets-empty)
(~blog-markets-list (~settings/markets-list
:items (map (lambda (m) :items (map (lambda (m)
(~blog-market-item (~settings/market-item
:name (get m "name") :name (get m "name")
:slug (get m "slug") :slug (get m "slug")
:delete-url (get m "delete_url") :delete-url (get m "delete_url")
@@ -93,11 +93,11 @@
;; Associated entries ;; Associated entries
(defcomp ~blog-entry-image (&key (src :as string?) (title :as string)) (defcomp ~settings/entry-image (&key (src :as string?) (title :as string))
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0") (if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))) (div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-associated-entry (&key (confirm-text :as string) (toggle-url :as string) hx-headers img (name :as string) (date-str :as string)) (defcomp ~settings/associated-entry (&key (confirm-text :as string) (toggle-url :as string) hx-headers img (name :as string) (date-str :as string))
(button :type "button" (button :type "button"
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100" :class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
:data-confirm "" :data-confirm-title "Remove entry?" :data-confirm "" :data-confirm-title "Remove entry?"
@@ -115,14 +115,14 @@
(div :class "text-xs text-stone-600 mt-1" date-str)) (div :class "text-xs text-stone-600 mt-1" date-str))
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0")))) (i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
(defcomp ~blog-associated-entries-content (&key items) (defcomp ~settings/associated-entries-content (&key items)
(div :class "space-y-1" items)) (div :class "space-y-1" items))
(defcomp ~blog-associated-entries-empty () (defcomp ~settings/associated-entries-empty ()
(div :class "text-sm text-stone-400" (div :class "text-sm text-stone-400"
"No entries associated yet. Browse calendars below to add entries.")) "No entries associated yet. Browse calendars below to add entries."))
(defcomp ~blog-associated-entries-panel (&key content) (defcomp ~settings/associated-entries-panel (&key content)
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white" (div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries") (h3 :class "text-lg font-semibold mb-4" "Associated Entries")
content)) content))
@@ -131,17 +131,17 @@
;; Associated entries composition — replaces _render_associated_entries ;; Associated entries composition — replaces _render_associated_entries
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-associated-entries-from-data (&key entries csrf) (defcomp ~settings/associated-entries-from-data (&key entries csrf)
(~blog-associated-entries-panel (~settings/associated-entries-panel
:content (if (empty? (or entries (list))) :content (if (empty? (or entries (list)))
(~blog-associated-entries-empty) (~settings/associated-entries-empty)
(~blog-associated-entries-content (~settings/associated-entries-content
:items (map (lambda (e) :items (map (lambda (e)
(~blog-associated-entry (~settings/associated-entry
:confirm-text (get e "confirm_text") :confirm-text (get e "confirm_text")
:toggle-url (get e "toggle_url") :toggle-url (get e "toggle_url")
:hx-headers {:X-CSRFToken csrf} :hx-headers {:X-CSRFToken csrf}
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title")) :img (~settings/entry-image :src (get e "cal_image") :title (get e "cal_title"))
:name (get e "name") :name (get e "name")
:date-str (get e "date_str"))) :date-str (get e "date_str")))
(or entries (list))))))) (or entries (list)))))))
@@ -150,7 +150,7 @@
;; Entries browser composition — replaces _h_post_entries_content ;; Entries browser composition — replaces _h_post_entries_content
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-calendar-browser-item (&key (name :as string) (title :as string) (image :as string?) (view-url :as string)) (defcomp ~settings/calendar-browser-item (&key (name :as string) (title :as string) (image :as string?) (view-url :as string))
(details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser" (details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser"
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3" (summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
(if image (if image
@@ -163,7 +163,7 @@
(div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML" (div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML"
(div :class "text-sm text-stone-400" "Loading calendar...")))) (div :class "text-sm text-stone-400" "Loading calendar..."))))
(defcomp ~blog-entries-browser-content (&key entries-panel calendars) (defcomp ~settings/entries-browser-content (&key entries-panel calendars)
(div :id "post-entries-content" :class "space-y-6 p-4" (div :id "post-entries-content" :class "space-y-6 p-4"
entries-panel entries-panel
(div :class "space-y-3" (div :class "space-y-3"
@@ -171,7 +171,7 @@
(if (empty? (or calendars (list))) (if (empty? (or calendars (list)))
(div :class "text-sm text-stone-400" "No calendars found.") (div :class "text-sm text-stone-400" "No calendars found.")
(map (lambda (cal) (map (lambda (cal)
(~blog-calendar-browser-item (~settings/calendar-browser-item
:name (get cal "name") :name (get cal "name")
:title (get cal "title") :title (get cal "title")
:image (get cal "image") :image (get cal "image")
@@ -182,17 +182,17 @@
;; Post settings form composition — replaces _h_post_settings_content ;; Post settings form composition — replaces _h_post_settings_content
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-field-label (&key (text :as string) (field-for :as string)) (defcomp ~settings/field-label (&key (text :as string) (field-for :as string))
(label :for field-for (label :for field-for
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text)) :class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
(defcomp ~blog-settings-section (&key (title :as string) content (is-open :as boolean)) (defcomp ~settings/section (&key (title :as string) content (is-open :as boolean))
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open (details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors" (summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
title) title)
(div :class "px-[16px] py-[12px] space-y-[12px]" content))) (div :class "px-[16px] py-[12px] space-y-[12px]" content)))
(defcomp ~blog-settings-form-content (&key csrf updated-at is-page save-success (defcomp ~settings/form-content (&key csrf updated-at is-page save-success
slug published-at featured visibility email-only slug published-at featured visibility email-only
tags feature-image-alt tags feature-image-alt
meta-title meta-description canonical-url meta-title meta-description canonical-url
@@ -209,19 +209,19 @@
(input :type "hidden" :name "updated_at" :value (or updated-at "")) (input :type "hidden" :name "updated_at" :value (or updated-at ""))
(div :class "space-y-[12px] mt-[16px]" (div :class "space-y-[12px] mt-[16px]"
;; General ;; General
(~blog-settings-section :title "General" :is-open true :content (~settings/section :title "General" :is-open true :content
(<> (<>
(div (~blog-settings-field-label :text "Slug" :field-for "settings-slug") (div (~settings/field-label :text "Slug" :field-for "settings-slug")
(input :type "text" :name "slug" :id "settings-slug" :value (or slug "") (input :type "text" :name "slug" :id "settings-slug" :value (or slug "")
:placeholder slug-placeholder :class input-cls)) :placeholder slug-placeholder :class input-cls))
(div (~blog-settings-field-label :text "Published at" :field-for "settings-published_at") (div (~settings/field-label :text "Published at" :field-for "settings-published_at")
(input :type "datetime-local" :name "published_at" :id "settings-published_at" (input :type "datetime-local" :name "published_at" :id "settings-published_at"
:value (or published-at "") :class input-cls)) :value (or published-at "") :class input-cls))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer" (div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured (input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300") :class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" featured-label))) (span :class "text-[14px] text-stone-600" featured-label)))
(div (~blog-settings-field-label :text "Visibility" :field-for "settings-visibility") (div (~settings/field-label :text "Visibility" :field-for "settings-visibility")
(select :name "visibility" :id "settings-visibility" :class input-cls (select :name "visibility" :id "settings-visibility" :class input-cls
(option :value "public" :selected (= visibility "public") "Public") (option :value "public" :selected (= visibility "public") "Public")
(option :value "members" :selected (= visibility "members") "Members") (option :value "members" :selected (= visibility "members") "Members")
@@ -231,57 +231,57 @@
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300") :class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" "Email only"))))) (span :class "text-[14px] text-stone-600" "Email only")))))
;; Tags ;; Tags
(~blog-settings-section :title "Tags" :content (~settings/section :title "Tags" :content
(div (~blog-settings-field-label :text "Tags (comma-separated)" :field-for "settings-tags") (div (~settings/field-label :text "Tags (comma-separated)" :field-for "settings-tags")
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "") (input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
:placeholder "news, updates, featured" :class input-cls) :placeholder "news, updates, featured" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically."))) (p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
;; Feature Image ;; Feature Image
(~blog-settings-section :title "Feature Image" :content (~settings/section :title "Feature Image" :content
(div (~blog-settings-field-label :text "Alt text" :field-for "settings-feature_image_alt") (div (~settings/field-label :text "Alt text" :field-for "settings-feature_image_alt")
(input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt" (input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt"
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls))) :value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
;; SEO / Meta ;; SEO / Meta
(~blog-settings-section :title "SEO / Meta" :content (~settings/section :title "SEO / Meta" :content
(<> (<>
(div (~blog-settings-field-label :text "Meta title" :field-for "settings-meta_title") (div (~settings/field-label :text "Meta title" :field-for "settings-meta_title")
(input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "") (input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "")
:placeholder "SEO title" :maxlength "300" :class input-cls) :placeholder "SEO title" :maxlength "300" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300.")) (p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300."))
(div (~blog-settings-field-label :text "Meta description" :field-for "settings-meta_description") (div (~settings/field-label :text "Meta description" :field-for "settings-meta_description")
(textarea :name "meta_description" :id "settings-meta_description" :rows "2" (textarea :name "meta_description" :id "settings-meta_description" :rows "2"
:placeholder "SEO description" :maxlength "500" :class textarea-cls :placeholder "SEO description" :maxlength "500" :class textarea-cls
(or meta-description "")) (or meta-description ""))
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters.")) (p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters."))
(div (~blog-settings-field-label :text "Canonical URL" :field-for "settings-canonical_url") (div (~settings/field-label :text "Canonical URL" :field-for "settings-canonical_url")
(input :type "url" :name "canonical_url" :id "settings-canonical_url" (input :type "url" :name "canonical_url" :id "settings-canonical_url"
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls)))) :value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
;; Facebook / OpenGraph ;; Facebook / OpenGraph
(~blog-settings-section :title "Facebook / OpenGraph" :content (~settings/section :title "Facebook / OpenGraph" :content
(<> (<>
(div (~blog-settings-field-label :text "OG title" :field-for "settings-og_title") (div (~settings/field-label :text "OG title" :field-for "settings-og_title")
(input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls)) (input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls))
(div (~blog-settings-field-label :text "OG description" :field-for "settings-og_description") (div (~settings/field-label :text "OG description" :field-for "settings-og_description")
(textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls (textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls
(or og-description ""))) (or og-description "")))
(div (~blog-settings-field-label :text "OG image URL" :field-for "settings-og_image") (div (~settings/field-label :text "OG image URL" :field-for "settings-og_image")
(input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "") (input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "")
:placeholder "https://..." :class input-cls)))) :placeholder "https://..." :class input-cls))))
;; X / Twitter ;; X / Twitter
(~blog-settings-section :title "X / Twitter" :content (~settings/section :title "X / Twitter" :content
(<> (<>
(div (~blog-settings-field-label :text "Twitter title" :field-for "settings-twitter_title") (div (~settings/field-label :text "Twitter title" :field-for "settings-twitter_title")
(input :type "text" :name "twitter_title" :id "settings-twitter_title" (input :type "text" :name "twitter_title" :id "settings-twitter_title"
:value (or twitter-title "") :class input-cls)) :value (or twitter-title "") :class input-cls))
(div (~blog-settings-field-label :text "Twitter description" :field-for "settings-twitter_description") (div (~settings/field-label :text "Twitter description" :field-for "settings-twitter_description")
(textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls (textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls
(or twitter-description ""))) (or twitter-description "")))
(div (~blog-settings-field-label :text "Twitter image URL" :field-for "settings-twitter_image") (div (~settings/field-label :text "Twitter image URL" :field-for "settings-twitter_image")
(input :type "url" :name "twitter_image" :id "settings-twitter_image" (input :type "url" :name "twitter_image" :id "settings-twitter_image"
:value (or twitter-image "") :placeholder "https://..." :class input-cls)))) :value (or twitter-image "") :placeholder "https://..." :class input-cls))))
;; Advanced ;; Advanced
(~blog-settings-section :title "Advanced" :content (~settings/section :title "Advanced" :content
(div (~blog-settings-field-label :text "Custom template" :field-for "settings-custom_template") (div (~settings/field-label :text "Custom template" :field-for "settings-custom_template")
(input :type "text" :name "custom_template" :id "settings-custom_template" (input :type "text" :name "custom_template" :id "settings-custom_template"
:value (or custom-template "") :placeholder tmpl-placeholder :class input-cls)))) :value (or custom-template "") :placeholder tmpl-placeholder :class input-cls))))
(div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200" (div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200"

View File

@@ -9,7 +9,7 @@
:auth :admin :auth :admin
:layout :blog :layout :blog
:data (editor-data) :data (editor-data)
:content (~blog-editor-content :content (~editor/content
:csrf csrf :title-placeholder title-placeholder :csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href :create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src :js-src js-src :sx-editor-js-src sx-editor-js-src
@@ -20,7 +20,7 @@
:auth :admin :auth :admin
:layout :blog :layout :blog
:data (editor-page-data) :data (editor-page-data)
:content (~blog-editor-content :content (~editor/content
:csrf csrf :title-placeholder title-placeholder :csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href :create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src :js-src js-src :sx-editor-js-src sx-editor-js-src
@@ -33,21 +33,21 @@
:auth :admin :auth :admin
:layout (:post-admin :selected "admin") :layout (:post-admin :selected "admin")
:data (post-admin-data slug) :data (post-admin-data slug)
:content (~blog-admin-placeholder)) :content (~admin/placeholder))
(defpage post-data (defpage post-data
:path "/<slug>/admin/data/" :path "/<slug>/admin/data/"
:auth :admin :auth :admin
:layout (:post-admin :selected "data") :layout (:post-admin :selected "data")
:data (post-data-data slug) :data (post-data-data slug)
:content (~blog-data-table-content :tablename tablename :model-data model-data)) :content (~admin/data-table-content :tablename tablename :model-data model-data))
(defpage post-preview (defpage post-preview
:path "/<slug>/admin/preview/" :path "/<slug>/admin/preview/"
:auth :admin :auth :admin
:layout (:post-admin :selected "preview") :layout (:post-admin :selected "preview")
:data (post-preview-data slug) :data (post-preview-data slug)
:content (~blog-preview-content :content (~admin/preview-content
:sx-pretty sx-pretty :json-pretty json-pretty :sx-pretty sx-pretty :json-pretty json-pretty
:sx-rendered sx-rendered :lex-rendered lex-rendered)) :sx-rendered sx-rendered :lex-rendered lex-rendered))
@@ -56,8 +56,8 @@
:auth :admin :auth :admin
:layout (:post-admin :selected "entries") :layout (:post-admin :selected "entries")
:data (post-entries-data slug) :data (post-entries-data slug)
:content (~blog-entries-browser-content :content (~settings/entries-browser-content
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf) :entries-panel (~settings/associated-entries-from-data :entries entries :csrf csrf)
:calendars calendars)) :calendars calendars))
(defpage post-settings (defpage post-settings
@@ -65,7 +65,7 @@
:auth :post_author :auth :post_author
:layout (:post-admin :selected "settings") :layout (:post-admin :selected "settings")
:data (post-settings-data slug) :data (post-settings-data slug)
:content (~blog-settings-form-content :content (~settings/form-content
:csrf csrf :updated-at updated-at :is-page is-page :csrf csrf :updated-at updated-at :is-page is-page
:save-success save-success :slug settings-slug :save-success save-success :slug settings-slug
:published-at published-at :featured featured :published-at published-at :featured featured
@@ -82,7 +82,7 @@
:auth :post_author :auth :post_author
:layout (:post-admin :selected "edit") :layout (:post-admin :selected "edit")
:data (post-edit-data slug) :data (post-edit-data slug)
:content (~blog-edit-content :content (~editor/edit-content
:csrf csrf :updated-at updated-at :csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val :title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption :feature-image feature-image :feature-image-caption feature-image-caption
@@ -111,7 +111,7 @@
:auth :admin :auth :admin
:layout :blog-cache :layout :blog-cache
:data (service "blog-page" "cache-data") :data (service "blog-page" "cache-data")
:content (~blog-cache-panel :clear-url clear-url :csrf csrf)) :content (~admin/cache-panel :clear-url clear-url :csrf csrf))
; --- Snippets --- ; --- Snippets ---
@@ -120,7 +120,7 @@
:auth :login :auth :login
:layout :blog-snippets :layout :blog-snippets
:data (service "blog-page" "snippets-data") :data (service "blog-page" "snippets-data")
:content (~blog-snippets-content :content (~admin/snippets-content
:snippets snippets :is-admin is-admin :csrf csrf)) :snippets snippets :is-admin is-admin :csrf csrf))
; --- Menu Items --- ; --- Menu Items ---
@@ -130,7 +130,7 @@
:auth :admin :auth :admin
:layout :blog-menu-items :layout :blog-menu-items
:data (service "blog-page" "menu-items-data") :data (service "blog-page" "menu-items-data")
:content (~blog-menu-items-content :content (~admin/menu-items-content
:menu-items menu-items :new-url new-url :csrf csrf)) :menu-items menu-items :new-url new-url :csrf csrf))
; --- Tag Groups --- ; --- Tag Groups ---
@@ -140,7 +140,7 @@
:auth :admin :auth :admin
:layout :blog-tag-groups :layout :blog-tag-groups
:data (service "blog-page" "tag-groups-data") :data (service "blog-page" "tag-groups-data")
:content (~blog-tag-groups-content :content (~admin/tag-groups-content
:groups groups :unassigned-tags unassigned-tags :groups groups :unassigned-tags unassigned-tags
:create-url create-url :csrf csrf)) :create-url create-url :csrf csrf))
@@ -149,6 +149,6 @@
:auth :admin :auth :admin
:layout :blog-tag-group-edit :layout :blog-tag-group-edit
:data (service "blog-page" "tag-group-edit-data" :id id) :data (service "blog-page" "tag-group-edit-data" :id id)
:content (~blog-tag-group-edit-content :content (~admin/tag-group-edit-content
:group group :all-tags all-tags :group group :all-tags all-tags
:save-url save-url :delete-url delete-url :csrf csrf)) :save-url save-url :delete-url delete-url :csrf csrf))

View File

@@ -167,7 +167,7 @@ class TestCards:
result = lexical_to_sx(_doc({ result = lexical_to_sx(_doc({
"type": "image", "src": "photo.jpg", "alt": "test" "type": "image", "src": "photo.jpg", "alt": "test"
})) }))
assert '(~kg-image :src "photo.jpg" :alt "test")' == result assert '(~kg_cards/kg-image :src "photo.jpg" :alt "test")' == result
def test_image_wide_with_caption(self): def test_image_wide_with_caption(self):
result = lexical_to_sx(_doc({ result = lexical_to_sx(_doc({
@@ -189,7 +189,7 @@ class TestCards:
"type": "bookmark", "url": "https://example.com", "type": "bookmark", "url": "https://example.com",
"metadata": {"title": "Example", "description": "A site"} "metadata": {"title": "Example", "description": "A site"}
})) }))
assert "(~kg-bookmark " in result assert "(~kg_cards/kg-bookmark " in result
assert ':url "https://example.com"' in result assert ':url "https://example.com"' in result
assert ':title "Example"' in result assert ':title "Example"' in result
@@ -199,7 +199,7 @@ class TestCards:
"calloutEmoji": "💡", "calloutEmoji": "💡",
"children": [_text("Note")] "children": [_text("Note")]
})) }))
assert "(~kg-callout " in result assert "(~kg_cards/kg-callout " in result
assert ':color "blue"' in result assert ':color "blue"' in result
def test_button(self): def test_button(self):
@@ -207,7 +207,7 @@ class TestCards:
"type": "button", "buttonText": "Click", "type": "button", "buttonText": "Click",
"buttonUrl": "https://example.com" "buttonUrl": "https://example.com"
})) }))
assert "(~kg-button " in result assert "(~kg_cards/kg-button " in result
assert ':text "Click"' in result assert ':text "Click"' in result
def test_toggle(self): def test_toggle(self):
@@ -215,28 +215,28 @@ class TestCards:
"type": "toggle", "heading": "FAQ", "type": "toggle", "heading": "FAQ",
"children": [_text("Answer")] "children": [_text("Answer")]
})) }))
assert "(~kg-toggle " in result assert "(~kg_cards/kg-toggle " in result
assert ':heading "FAQ"' in result assert ':heading "FAQ"' in result
def test_html(self): def test_html(self):
result = lexical_to_sx(_doc({ result = lexical_to_sx(_doc({
"type": "html", "html": "<div>custom</div>" "type": "html", "html": "<div>custom</div>"
})) }))
assert result == '(~kg-html (div "custom"))' assert result == '(~kg_cards/kg-html (div "custom"))'
def test_embed(self): def test_embed(self):
result = lexical_to_sx(_doc({ result = lexical_to_sx(_doc({
"type": "embed", "html": "<iframe></iframe>", "type": "embed", "html": "<iframe></iframe>",
"caption": "Video" "caption": "Video"
})) }))
assert "(~kg-embed " in result assert "(~kg_cards/kg-embed " in result
assert ':caption "Video"' in result assert ':caption "Video"' in result
def test_markdown(self): def test_markdown(self):
result = lexical_to_sx(_doc({ result = lexical_to_sx(_doc({
"type": "markdown", "markdown": "**bold** text" "type": "markdown", "markdown": "**bold** text"
})) }))
assert result.startswith("(~kg-md ") assert result.startswith("(~kg_cards/kg-md ")
assert "(p " in result assert "(p " in result
assert "(strong " in result assert "(strong " in result
@@ -244,14 +244,14 @@ class TestCards:
result = lexical_to_sx(_doc({ result = lexical_to_sx(_doc({
"type": "video", "src": "v.mp4", "cardWidth": "wide" "type": "video", "src": "v.mp4", "cardWidth": "wide"
})) }))
assert "(~kg-video " in result assert "(~kg_cards/kg-video " in result
assert ':width "wide"' in result assert ':width "wide"' in result
def test_audio(self): def test_audio(self):
result = lexical_to_sx(_doc({ result = lexical_to_sx(_doc({
"type": "audio", "src": "s.mp3", "title": "Song", "duration": 195 "type": "audio", "src": "s.mp3", "title": "Song", "duration": 195
})) }))
assert "(~kg-audio " in result assert "(~kg_cards/kg-audio " in result
assert ':duration "3:15"' in result assert ':duration "3:15"' in result
def test_file(self): def test_file(self):
@@ -259,13 +259,13 @@ class TestCards:
"type": "file", "src": "f.pdf", "fileName": "doc.pdf", "type": "file", "src": "f.pdf", "fileName": "doc.pdf",
"fileSize": 2100000 "fileSize": 2100000
})) }))
assert "(~kg-file " in result assert "(~kg_cards/kg-file " in result
assert ':filename "doc.pdf"' in result assert ':filename "doc.pdf"' in result
assert "MB" in result assert "MB" in result
def test_paywall(self): def test_paywall(self):
result = lexical_to_sx(_doc({"type": "paywall"})) result = lexical_to_sx(_doc({"type": "paywall"}))
assert result == "(~kg-paywall)" assert result == "(~kg_cards/kg-paywall)"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

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

View File

@@ -4,6 +4,6 @@
;; Renders the "orders" link for the account dashboard nav. ;; Renders the "orders" link for the account dashboard nav.
(defhandler account-nav-item (&key) (defhandler account-nav-item (&key)
(~account-nav-item (~shared:fragments/account-nav-item
:href (app-url "cart" "/orders/") :href (app-url "cart" "/orders/")
:label "orders")) :label "orders"))

View File

@@ -10,7 +10,7 @@
(count (+ (or (get summary "count") 0) (count (+ (or (get summary "count") 0)
(or (get summary "calendar_count") 0) (or (get summary "calendar_count") 0)
(or (get summary "ticket_count") 0)))) (or (get summary "ticket_count") 0))))
(~cart-mini (~shared:fragments/cart-mini
:cart-count count :cart-count count
:blog-url (app-url "blog" "") :blog-url (app-url "blog" "")
:cart-url (app-url "cart" "") :cart-url (app-url "cart" "")

View File

@@ -1,14 +1,14 @@
;; Cart header components ;; Cart header components
(defcomp ~cart-page-label-img (&key src) (defcomp ~header/page-label-img (&key src)
(img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) (img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~cart-page-label (&key feature-image title) (defcomp ~header/page-label (&key feature-image title)
(<> (when feature-image (<> (when feature-image
(~cart-page-label-img :src feature-image)) (~header/page-label-img :src feature-image))
(span title))) (span title)))
(defcomp ~cart-all-carts-link (&key href) (defcomp ~header/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" (a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")) (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))

View File

@@ -1,29 +1,29 @@
;; Cart item components ;; Cart item components
(defcomp ~cart-item-img (&key (src :as string) (alt :as string)) (defcomp ~items/img (&key (src :as string) (alt :as string))
(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")) (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-price (&key (text :as string)) (defcomp ~items/price (&key (text :as string))
(p :class "text-sm sm:text-base font-semibold text-stone-900" text)) (p :class "text-sm sm:text-base font-semibold text-stone-900" text))
(defcomp ~cart-item-price-was (&key (text :as string)) (defcomp ~items/price-was (&key (text :as string))
(p :class "text-xs text-stone-400 line-through" text)) (p :class "text-xs text-stone-400 line-through" text))
(defcomp ~cart-item-no-price () (defcomp ~items/no-price ()
(p :class "text-xs text-stone-500" "No price")) (p :class "text-xs text-stone-500" "No price"))
(defcomp ~cart-item-deleted () (defcomp ~items/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" (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 :class "fa-solid fa-triangle-exclamation text-[0.6rem]" :aria-hidden "true")
" This item is no longer available or price has changed")) " This item is no longer available or price has changed"))
(defcomp ~cart-item-brand (&key (brand :as string)) (defcomp ~items/brand (&key (brand :as string))
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" brand)) (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" brand))
(defcomp ~cart-item-line-total (&key (text :as string)) (defcomp ~items/line-total (&key (text :as string))
(p :class "text-sm sm:text-base font-semibold text-stone-900" text)) (p :class "text-sm sm:text-base font-semibold text-stone-900" text))
(defcomp ~cart-item (&key (id :as string) img (prod-url :as string) (title :as string) brand deleted price (qty-url :as string) (csrf :as string) (minus :as string) (qty :as string) (plus :as string) line-total) (defcomp ~items/index (&key (id :as string) img (prod-url :as string) (title :as string) brand deleted price (qty-url :as string) (csrf :as string) (minus :as string) (qty :as string) (plus :as string) line-total)
(article :id id :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5" (article :id id :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
(div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (when img img)) (div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (when img img))
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
@@ -47,14 +47,14 @@
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+"))) (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))
(div :class "flex items-center justify-between sm:justify-end gap-3" (when line-total line-total)))))) (div :class "flex items-center justify-between sm:justify-end gap-3" (when line-total line-total))))))
(defcomp ~cart-page-panel (&key items cal tickets summary) (defcomp ~items/page-panel (&key items cal tickets summary)
(div :class "max-w-full px-3 py-3 space-y-3" (div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart" (div :id "cart"
(div (section :class "space-y-3 sm:space-y-4" items cal tickets) (div (section :class "space-y-3 sm:space-y-4" items cal tickets)
summary)))) summary))))
;; Assembled cart item from serialized data — replaces Python _cart_item_sx ;; Assembled cart item from serialized data — replaces Python _cart_item_sx
(defcomp ~cart-item-from-data (&key (item :as dict)) (defcomp ~items/from-data (&key (item :as dict))
(let* ((slug (or (get item "slug") "")) (let* ((slug (or (get item "slug") ""))
(title (or (get item "title") "")) (title (or (get item "title") ""))
(image (get item "image")) (image (get item "image"))
@@ -71,48 +71,48 @@
(qty-url (or (get item "qty_url") "")) (qty-url (or (get item "qty_url") ""))
(csrf (csrf-token)) (csrf (csrf-token))
(line-total (when unit-price (* unit-price quantity)))) (line-total (when unit-price (* unit-price quantity))))
(~cart-item (~items/index
:id (str "cart-item-" slug) :id (str "cart-item-" slug)
:img (if image :img (if image
(~cart-item-img :src image :alt title) (~items/img :src image :alt title)
(~img-or-placeholder :src nil (~shared:misc/img-or-placeholder :src nil
:size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300" :size-cls "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300"
:placeholder-text "No image")) :placeholder-text "No image"))
:prod-url prod-url :prod-url prod-url
:title title :title title
:brand (when brand (~cart-item-brand :brand brand)) :brand (when brand (~items/brand :brand brand))
:deleted (when is-deleted (~cart-item-deleted)) :deleted (when is-deleted (~items/deleted))
:price (if unit-price :price (if unit-price
(<> (<>
(~cart-item-price :text (str symbol (format-decimal unit-price 2))) (~items/price :text (str symbol (format-decimal unit-price 2)))
(when (and special-price (!= special-price regular-price)) (when (and special-price (!= special-price regular-price))
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2))))) (~items/price-was :text (str symbol (format-decimal regular-price 2)))))
(~cart-item-no-price)) (~items/no-price))
:qty-url qty-url :csrf csrf :qty-url qty-url :csrf csrf
:minus (str (- quantity 1)) :minus (str (- quantity 1))
:qty (str quantity) :qty (str quantity)
:plus (str (+ quantity 1)) :plus (str (+ quantity 1))
:line-total (when line-total :line-total (when line-total
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2))))))) (~items/line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
;; Assembled calendar entries section — replaces Python _calendar_entries_sx ;; Assembled calendar entries section — replaces Python _calendar_entries_sx
(defcomp ~cart-cal-section-from-data (&key (entries :as list)) (defcomp ~items/cal-section-from-data (&key (entries :as list))
(when (not (empty? entries)) (when (not (empty? entries))
(~cart-cal-section (~calendar/cal-section
:items (map (lambda (e) :items (map (lambda (e)
(let* ((name (or (get e "name") "")) (let* ((name (or (get e "name") ""))
(date-str (or (get e "date_str") ""))) (date-str (or (get e "date_str") "")))
(~cart-cal-entry (~calendar/cal-entry
:name name :date-str date-str :name name :date-str date-str
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2))))) :cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
entries)))) entries))))
;; Assembled ticket groups section — replaces Python _ticket_groups_sx ;; Assembled ticket groups section — replaces Python _ticket_groups_sx
(defcomp ~cart-tickets-section-from-data (&key (ticket-groups :as list)) (defcomp ~items/tickets-section-from-data (&key (ticket-groups :as list))
(when (not (empty? ticket-groups)) (when (not (empty? ticket-groups))
(let* ((csrf (csrf-token)) (let* ((csrf (csrf-token))
(qty-url (url-for "cart_global.update_ticket_quantity"))) (qty-url (url-for "cart_global.update_ticket_quantity")))
(~cart-tickets-section (~tickets/section
:items (map (lambda (tg) :items (map (lambda (tg)
(let* ((name (or (get tg "entry_name") "")) (let* ((name (or (get tg "entry_name") ""))
(tt-name (get tg "ticket_type_name")) (tt-name (get tg "ticket_type_name"))
@@ -122,14 +122,14 @@
(entry-id (str (or (get tg "entry_id") ""))) (entry-id (str (or (get tg "entry_id") "")))
(tt-id (get tg "ticket_type_id")) (tt-id (get tg "ticket_type_id"))
(date-str (or (get tg "date_str") ""))) (date-str (or (get tg "date_str") "")))
(~cart-ticket-article (~tickets/article
:name name :name name
:type-name (when tt-name (~cart-ticket-type-name :name tt-name)) :type-name (when tt-name (~tickets/type-name :name tt-name))
:date-str date-str :date-str date-str
:price (str "\u00a3" (format-decimal price 2)) :price (str "\u00a3" (format-decimal price 2))
:qty-url qty-url :csrf csrf :qty-url qty-url :csrf csrf
:entry-id entry-id :entry-id entry-id
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id))) :type-hidden (when tt-id (~tickets/type-hidden :value (str tt-id)))
:minus (str (max (- quantity 1) 0)) :minus (str (max (- quantity 1) 0))
:qty (str quantity) :qty (str quantity)
:plus (str (+ quantity 1)) :plus (str (+ quantity 1))
@@ -137,29 +137,29 @@
ticket-groups))))) ticket-groups)))))
;; Assembled cart summary — replaces Python _cart_summary_sx ;; Assembled cart summary — replaces Python _cart_summary_sx
(defcomp ~cart-summary-from-data (&key (item-count :as number) (grand-total :as number) (symbol :as string) (is-logged-in :as boolean) (checkout-action :as string) (login-href :as string) (user-email :as string?)) (defcomp ~items/summary-from-data (&key (item-count :as number) (grand-total :as number) (symbol :as string) (is-logged-in :as boolean) (checkout-action :as string) (login-href :as string) (user-email :as string?))
(~cart-summary-panel (~summary/panel
:item-count (str item-count) :item-count (str item-count)
:subtotal (str symbol (format-decimal grand-total 2)) :subtotal (str symbol (format-decimal grand-total 2))
:checkout (if is-logged-in :checkout (if is-logged-in
(~cart-checkout-form (~summary/checkout-form
:action checkout-action :csrf (csrf-token) :action checkout-action :csrf (csrf-token)
:label (str " Checkout as " user-email)) :label (str " Checkout as " user-email))
(~cart-checkout-signin :href login-href)))) (~summary/checkout-signin :href login-href))))
;; Assembled page cart content — replaces Python _page_cart_main_panel_sx ;; Assembled page cart content — replaces Python _page_cart_main_panel_sx
(defcomp ~cart-page-cart-content (&key (cart-items :as list?) (cal-entries :as list?) (ticket-groups :as list?) summary) (defcomp ~items/page-cart-content (&key (cart-items :as list?) (cal-entries :as list?) (ticket-groups :as list?) summary)
(if (and (empty? (or cart-items (list))) (if (and (empty? (or cart-items (list)))
(empty? (or cal-entries (list))) (empty? (or cal-entries (list)))
(empty? (or ticket-groups (list)))) (empty? (or ticket-groups (list))))
(div :class "max-w-full px-3 py-3 space-y-3" (div :class "max-w-full px-3 py-3 space-y-3"
(div :id "cart" (div :id "cart"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center")))) (~shared:misc/empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
(~cart-page-panel (~items/page-panel
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list))) :items (map (lambda (item) (~items/from-data :item item)) (or cart-items (list)))
:cal (when (not (empty? (or cal-entries (list)))) :cal (when (not (empty? (or cal-entries (list))))
(~cart-cal-section-from-data :entries cal-entries)) (~items/cal-section-from-data :entries cal-entries))
:tickets (when (not (empty? (or ticket-groups (list)))) :tickets (when (not (empty? (or ticket-groups (list))))
(~cart-tickets-section-from-data :ticket-groups ticket-groups)) (~items/tickets-section-from-data :ticket-groups ticket-groups))
:summary summary))) :summary summary)))

View File

@@ -10,17 +10,17 @@
(quasiquote (quasiquote
(let ((__cpctx (cart-page-ctx))) (let ((__cpctx (cart-page-ctx)))
(<> (<>
(~menu-row-sx :id "cart-row" :level 1 :colour "sky" (~shared:layout/menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url") :link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart" :link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child") :child-id "cart-header-child")
(~header-child-sx :id "cart-header-child" (~shared:layout/header-child-sx :id "cart-header-child"
:inner (~menu-row-sx :id "page-cart-row" :level 2 :colour "sky" :inner (~shared:layout/menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url") :link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label :link-label-content (~header/page-label
:feature-image (get __cpctx "feature-image") :feature-image (get __cpctx "feature-image")
:title (get __cpctx "title")) :title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url")) :nav (~header/all-carts-link :href (get __cpctx "cart-url"))
:oob (unquote oob))))))) :oob (unquote oob)))))))
(defmacro ~cart-page-header-oob () (defmacro ~cart-page-header-oob ()
@@ -28,14 +28,14 @@
(quasiquote (quasiquote
(let ((__cpctx (cart-page-ctx))) (let ((__cpctx (cart-page-ctx)))
(<> (<>
(~menu-row-sx :id "page-cart-row" :level 2 :colour "sky" (~shared:layout/menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
:link-href (get __cpctx "page-cart-url") :link-href (get __cpctx "page-cart-url")
:link-label-content (~cart-page-label :link-label-content (~header/page-label
:feature-image (get __cpctx "feature-image") :feature-image (get __cpctx "feature-image")
:title (get __cpctx "title")) :title (get __cpctx "title"))
:nav (~cart-all-carts-link :href (get __cpctx "cart-url")) :nav (~header/all-carts-link :href (get __cpctx "cart-url"))
:oob true) :oob true)
(~menu-row-sx :id "cart-row" :level 1 :colour "sky" (~shared:layout/menu-row-sx :id "cart-row" :level 1 :colour "sky"
:link-href (get __cpctx "cart-url") :link-href (get __cpctx "cart-url")
:link-label "cart" :icon "fa fa-shopping-cart" :link-label "cart" :icon "fa fa-shopping-cart"
:child-id "cart-header-child" :child-id "cart-header-child"
@@ -45,12 +45,12 @@
;; cart-page layout: root + cart row + page-cart row ;; cart-page layout: root + cart row + page-cart row
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~cart-page-layout-full () (defcomp ~layouts/page-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (~cart-page-header-auto)))) :inner (~cart-page-header-auto))))
(defcomp ~cart-page-layout-oob () (defcomp ~layouts/page-layout-oob ()
(<> (~cart-page-header-oob) (<> (~cart-page-header-oob)
(~root-header-auto true))) (~root-header-auto true)))
@@ -59,14 +59,14 @@
;; Uses (post-header-ctx) — requires :data handler to populate g._defpage_ctx ;; Uses (post-header-ctx) — requires :data handler to populate g._defpage_ctx
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~cart-admin-layout-full (&key selected) (defcomp ~layouts/admin-layout-full (&key selected)
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (~post-header-auto nil)))) :inner (~post-header-auto nil))))
(defcomp ~cart-admin-layout-oob (&key selected) (defcomp ~layouts/admin-layout-oob (&key selected)
(<> (~post-header-auto true) (<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child" (~shared:layout/oob-header-sx :parent-id "post-header-child"
:row (~post-admin-header-auto nil selected)) :row (~post-admin-header-auto nil selected))
(~root-header-auto true))) (~root-header-auto true)))
@@ -74,63 +74,63 @@
;; orders-within-cart: root + auth-simple + orders ;; orders-within-cart: root + auth-simple + orders
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~cart-orders-layout-full (&key list-url) (defcomp ~layouts/orders-layout-full (&key list-url)
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~auth-header-row-simple-auto) :inner (<> (~auth-header-row-simple-auto)
(~header-child-sx :id "auth-header-child" (~shared:layout/header-child-sx :id "auth-header-child"
:inner (~orders-header-row :list-url list-url)))))) :inner (~shared:auth/orders-header-row :list-url list-url))))))
(defcomp ~cart-orders-layout-oob (&key list-url) (defcomp ~layouts/orders-layout-oob (&key list-url)
(<> (~auth-header-row-simple-auto true) (<> (~auth-header-row-simple-auto true)
(~oob-header-sx (~shared:layout/oob-header-sx
:parent-id "auth-header-child" :parent-id "auth-header-child"
:row (~orders-header-row :list-url list-url)) :row (~shared:auth/orders-header-row :list-url list-url))
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; order-detail-within-cart: root + auth-simple + orders + order ;; order-detail-within-cart: root + auth-simple + orders + order
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~cart-order-detail-layout-full (&key list-url detail-url order-label) (defcomp ~layouts/order-detail-layout-full (&key list-url detail-url order-label)
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~auth-header-row-simple-auto) :inner (<> (~auth-header-row-simple-auto)
(~header-child-sx :id "auth-header-child" (~shared:layout/header-child-sx :id "auth-header-child"
:inner (<> (~orders-header-row :list-url list-url) :inner (<> (~shared:auth/orders-header-row :list-url list-url)
(~header-child-sx :id "orders-header-child" (~shared:layout/header-child-sx :id "orders-header-child"
:inner (~menu-row-sx :id "order-row" :level 3 :colour "sky" :inner (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url :link-href detail-url
:link-label order-label :link-label order-label
:icon "fa fa-gbp")))))))) :icon "fa fa-gbp"))))))))
(defcomp ~cart-order-detail-layout-oob (&key detail-url order-label) (defcomp ~layouts/order-detail-layout-oob (&key detail-url order-label)
(<> (~oob-header-sx (<> (~shared:layout/oob-header-sx
:parent-id "orders-header-child" :parent-id "orders-header-child"
:row (~menu-row-sx :id "order-row" :level 3 :colour "sky" :row (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href detail-url :link-label order-label :link-href detail-url :link-label order-label
:icon "fa fa-gbp" :oob true)) :icon "fa fa-gbp" :oob true))
(~root-header-auto true))) (~root-header-auto true)))
;; --- orders rows wrapper (for infinite scroll) --- ;; --- orders rows wrapper (for infinite scroll) ---
(defcomp ~cart-orders-rows (&key rows next-scroll) (defcomp ~layouts/orders-rows (&key rows next-scroll)
(<> rows next-scroll)) (<> rows next-scroll))
;; Composition defcomp — replaces Python loop in render_orders_rows ;; Composition defcomp — replaces Python loop in render_orders_rows
(defcomp ~cart-orders-rows-content (&key orders detail-url-prefix page total-pages next-url) (defcomp ~layouts/orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
(~cart-orders-rows (~layouts/orders-rows
:rows (map (lambda (od) :rows (map (lambda (od)
(~order-row-pair :order od :detail-url-prefix detail-url-prefix)) (~shared:orders/row-pair :order od :detail-url-prefix detail-url-prefix))
(or orders (list))) (or orders (list)))
:next-scroll (if (< page total-pages) :next-scroll (if (< page total-pages)
(~infinite-scroll :url next-url :page page (~shared:controls/infinite-scroll :url next-url :page page
:total-pages total-pages :id-prefix "orders" :colspan 5) :total-pages total-pages :id-prefix "orders" :colspan 5)
(~order-end-row)))) (~shared:orders/end-row))))
;; Composition defcomp — replaces conditional composition in render_checkout_error_page ;; Composition defcomp — replaces conditional composition in render_checkout_error_page
(defcomp ~cart-checkout-error-from-data (&key msg order-id back-url) (defcomp ~layouts/checkout-error-from-data (&key msg order-id back-url)
(~checkout-error-content (~shared:orders/checkout-error-content
:msg msg :msg msg
:order (when order-id (~checkout-error-order-id :oid (str "#" order-id))) :order (when order-id (~shared:orders/checkout-error-order-id :oid (str "#" order-id)))
:back-url back-url)) :back-url back-url))

View File

@@ -1,20 +1,20 @@
;; Cart overview components ;; Cart overview components
(defcomp ~cart-badge (&key (icon :as string) (text :as string)) (defcomp ~overview/badge (&key (icon :as string) (text :as string))
(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100" (span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100"
(i :class icon :aria-hidden "true") text)) (i :class icon :aria-hidden "true") text))
(defcomp ~cart-badges-wrap (&key badges) (defcomp ~overview/badges-wrap (&key badges)
(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" (div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600"
badges)) badges))
(defcomp ~cart-group-card-img (&key (src :as string) (alt :as string)) (defcomp ~overview/group-card-img (&key (src :as string) (alt :as string))
(img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0")) (img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0"))
(defcomp ~cart-mp-subtitle (&key (title :as string)) (defcomp ~overview/mp-subtitle (&key (title :as string))
(p :class "text-xs text-stone-500 truncate" title)) (p :class "text-xs text-stone-500 truncate" title))
(defcomp ~cart-group-card (&key (href :as string) img (display-title :as string) subtitle badges (total :as string)) (defcomp ~overview/group-card (&key (href :as string) img (display-title :as string) subtitle badges (total :as string))
(a :href href :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5" (a :href href :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
(div :class "flex items-start gap-4" (div :class "flex items-start gap-4"
img img
@@ -25,7 +25,7 @@
(div :class "text-lg font-bold text-stone-900" total) (div :class "text-lg font-bold text-stone-900" total)
(div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192"))))) (div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192")))))
(defcomp ~cart-orphan-card (&key badges (total :as string)) (defcomp ~overview/orphan-card (&key badges (total :as string))
(div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5" (div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5"
(div :class "flex items-start gap-4" (div :class "flex items-start gap-4"
(div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0" (div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0"
@@ -36,17 +36,17 @@
(div :class "text-right flex-shrink-0" (div :class "text-right flex-shrink-0"
(div :class "text-lg font-bold text-stone-900" total))))) (div :class "text-lg font-bold text-stone-900" total)))))
(defcomp ~cart-overview-panel (&key cards) (defcomp ~overview/panel (&key cards)
(div :class "max-w-full px-3 py-3 space-y-3" (div :class "max-w-full px-3 py-3 space-y-3"
(div :class "space-y-4" cards))) (div :class "space-y-4" cards)))
(defcomp ~cart-empty () (defcomp ~overview/empty ()
(div :class "max-w-full px-3 py-3 space-y-3" (div :class "max-w-full px-3 py-3 space-y-3"
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center")))) (~shared:misc/empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
;; Assembled page group card — replaces Python _page_group_card_sx ;; Assembled page group card — replaces Python _page_group_card_sx
(defcomp ~cart-page-group-card-from-data (&key (grp :as dict) (cart-url-base :as string)) (defcomp ~overview/page-group-card-from-data (&key (grp :as dict) (cart-url-base :as string))
(let* ((post (get grp "post")) (let* ((post (get grp "post"))
(product-count (or (get grp "product_count") 0)) (product-count (or (get grp "product_count") 0))
(calendar-count (or (get grp "calendar_count") 0)) (calendar-count (or (get grp "calendar_count") 0))
@@ -55,13 +55,13 @@
(market-place (get grp "market_place")) (market-place (get grp "market_place"))
(badges (<> (badges (<>
(when (> product-count 0) (when (> product-count 0)
(~cart-badge :icon "fa fa-box-open" (~overview/badge :icon "fa fa-box-open"
:text (str product-count " item" (pluralize product-count)))) :text (str product-count " item" (pluralize product-count))))
(when (> calendar-count 0) (when (> calendar-count 0)
(~cart-badge :icon "fa fa-calendar" (~overview/badge :icon "fa fa-calendar"
:text (str calendar-count " booking" (pluralize calendar-count)))) :text (str calendar-count " booking" (pluralize calendar-count))))
(when (> ticket-count 0) (when (> ticket-count 0)
(~cart-badge :icon "fa fa-ticket" (~overview/badge :icon "fa fa-ticket"
:text (str ticket-count " ticket" (pluralize ticket-count))))))) :text (str ticket-count " ticket" (pluralize ticket-count)))))))
(if post (if post
(let* ((slug (or (get post "slug") "")) (let* ((slug (or (get post "slug") ""))
@@ -69,26 +69,26 @@
(feature-image (get post "feature_image")) (feature-image (get post "feature_image"))
(mp-name (if market-place (or (get market-place "name") "") "")) (mp-name (if market-place (or (get market-place "name") "") ""))
(display-title (if (!= mp-name "") mp-name title))) (display-title (if (!= mp-name "") mp-name title)))
(~cart-group-card (~overview/group-card
:href (str cart-url-base "/" slug "/") :href (str cart-url-base "/" slug "/")
:img (if feature-image :img (if feature-image
(~cart-group-card-img :src feature-image :alt title) (~overview/group-card-img :src feature-image :alt title)
(~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl" (~shared:misc/img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
:placeholder-icon "fa fa-store text-xl")) :placeholder-icon "fa fa-store text-xl"))
:display-title display-title :display-title display-title
:subtitle (when (!= mp-name "") :subtitle (when (!= mp-name "")
(~cart-mp-subtitle :title title)) (~overview/mp-subtitle :title title))
:badges (~cart-badges-wrap :badges badges) :badges (~overview/badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2)))) :total (str "\u00a3" (format-decimal total 2))))
(~cart-orphan-card (~overview/orphan-card
:badges (~cart-badges-wrap :badges badges) :badges (~overview/badges-wrap :badges badges)
:total (str "\u00a3" (format-decimal total 2)))))) :total (str "\u00a3" (format-decimal total 2))))))
;; Assembled cart overview content — replaces Python _overview_main_panel_sx ;; Assembled cart overview content — replaces Python _overview_main_panel_sx
(defcomp ~cart-overview-content (&key (page-groups :as list) (cart-url-base :as string)) (defcomp ~overview/content (&key (page-groups :as list) (cart-url-base :as string))
(if (empty? page-groups) (if (empty? page-groups)
(~cart-empty) (~overview/empty)
(~cart-overview-panel (~overview/panel
:cards (map (lambda (grp) :cards (map (lambda (grp)
(~cart-page-group-card-from-data :grp grp :cart-url-base cart-url-base)) (~overview/page-group-card-from-data :grp grp :cart-url-base cart-url-base))
page-groups)))) page-groups))))

View File

@@ -1,13 +1,13 @@
;; Cart payments components ;; Cart payments components
(defcomp ~cart-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix) (defcomp ~payments/panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
(section :class "p-4 max-w-lg mx-auto" (section :class "p-4 max-w-lg mx-auto"
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code (~shared:misc/sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured :placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :sx-select "#payments-panel"))) :checkout-prefix checkout-prefix :sx-select "#payments-panel")))
;; Assembled cart admin overview content ;; Assembled cart admin overview content
(defcomp ~cart-admin-content () (defcomp ~payments/admin-content ()
(let* ((payments-href (url-for "defpage_cart_payments"))) (let* ((payments-href (url-for "defpage_cart_payments")))
(div :id "main-panel" (div :id "main-panel"
(div :class "flex items-center justify-between p-3 border-b" (div :class "flex items-center justify-between p-3 border-b"
@@ -15,13 +15,13 @@
(a :href payments-href :class "text-sm underline" "configure"))))) (a :href payments-href :class "text-sm underline" "configure")))))
;; Assembled cart payments content ;; Assembled cart payments content
(defcomp ~cart-payments-content (&key page-config) (defcomp ~payments/content (&key page-config)
(let* ((sumup-configured (and page-config (get page-config "sumup_api_key"))) (let* ((sumup-configured (and page-config (get page-config "sumup_api_key")))
(merchant-code (or (get page-config "sumup_merchant_code") "")) (merchant-code (or (get page-config "sumup_merchant_code") ""))
(checkout-prefix (or (get page-config "sumup_checkout_prefix") "")) (checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
(placeholder (if sumup-configured "--------" "sup_sk_...")) (placeholder (if sumup-configured "--------" "sup_sk_..."))
(input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")) (input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(~cart-payments-panel (~payments/panel
:update-url (url-for "page_admin.update_sumup") :update-url (url-for "page_admin.update_sumup")
:csrf (csrf-token) :csrf (csrf-token)
:merchant-code merchant-code :merchant-code merchant-code

View File

@@ -1,17 +1,17 @@
;; Cart summary / checkout components ;; Cart summary / checkout components
(defcomp ~cart-checkout-form (&key (action :as string) (csrf :as string) (label :as string)) (defcomp ~summary/checkout-form (&key (action :as string) (csrf :as string) (label :as string))
(form :method "post" :action action :class "w-full" (form :method "post" :action action :class "w-full"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition" (button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
(i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") label))) (i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") label)))
(defcomp ~cart-checkout-signin (&key (href :as string)) (defcomp ~summary/checkout-signin (&key (href :as string))
(div :class "w-full flex" (div :class "w-full flex"
(a :href href :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition" (a :href href :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
(i :class "fa-solid fa-key") (span "sign in or register to checkout")))) (i :class "fa-solid fa-key") (span "sign in or register to checkout"))))
(defcomp ~cart-summary-panel (&key (item-count :as string) (subtotal :as string) checkout) (defcomp ~summary/panel (&key (item-count :as string) (subtotal :as string) checkout)
(aside :id "cart-summary" :class "lg:pl-2" (aside :id "cart-summary" :class "lg:pl-2"
(div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5" (div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5"
(h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary") (h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary")

View File

@@ -1,12 +1,12 @@
;; Cart ticket components ;; Cart ticket components
(defcomp ~cart-ticket-type-name (&key (name :as string)) (defcomp ~tickets/type-name (&key (name :as string))
(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" name)) (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" name))
(defcomp ~cart-ticket-type-hidden (&key (value :as string)) (defcomp ~tickets/type-hidden (&key (value :as string))
(input :type "hidden" :name "ticket_type_id" :value value)) (input :type "hidden" :name "ticket_type_id" :value value))
(defcomp ~cart-ticket-article (&key (name :as string) type-name (date-str :as string) (price :as string) (qty-url :as string) (csrf :as string) (entry-id :as string) type-hidden (minus :as string) (qty :as string) (plus :as string) (line-total :as string)) (defcomp ~tickets/article (&key (name :as string) type-name (date-str :as string) (price :as string) (qty-url :as string) (csrf :as string) (entry-id :as string) type-hidden (minus :as string) (qty :as string) (plus :as string) (line-total :as string))
(article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4" (article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4"
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3" (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"
@@ -35,7 +35,7 @@
(div :class "flex items-center justify-between sm:justify-end gap-3" (div :class "flex items-center justify-between sm:justify-end gap-3"
(p :class "text-sm sm:text-base font-semibold text-stone-900" line-total)))))) (p :class "text-sm sm:text-base font-semibold text-stone-900" line-total))))))
(defcomp ~cart-tickets-section (&key items) (defcomp ~tickets/section (&key items)
(div :class "mt-6 border-t border-stone-200 pt-4" (div :class "mt-6 border-t border-stone-200 pt-4"
(h2 :class "text-base font-semibold mb-2" (h2 :class "text-base font-semibold mb-2"
(i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets") (i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets")

View File

@@ -6,7 +6,7 @@
:auth :public :auth :public
:layout :root :layout :root
:data (service "cart-page" "overview-data") :data (service "cart-page" "overview-data")
:content (~cart-overview-content :content (~overview/content
:page-groups page-groups :page-groups page-groups
:cart-url-base cart-url-base)) :cart-url-base cart-url-base))
@@ -15,11 +15,11 @@
:auth :public :auth :public
:layout :cart-page :layout :cart-page
:data (service "cart-page" "page-cart-data") :data (service "cart-page" "page-cart-data")
:content (~cart-page-cart-content :content (~items/page-cart-content
:cart-items cart-items :cart-items cart-items
:cal-entries cal-entries :cal-entries cal-entries
:ticket-groups ticket-groups :ticket-groups ticket-groups
:summary (~cart-summary-from-data :summary (~items/summary-from-data
:item-count (get summary "item_count") :item-count (get summary "item_count")
:grand-total (get summary "grand_total") :grand-total (get summary "grand_total")
:symbol (get summary "symbol") :symbol (get summary "symbol")
@@ -33,12 +33,12 @@
:auth :admin :auth :admin
:layout :cart-admin :layout :cart-admin
:data (service "cart-page" "admin-data") :data (service "cart-page" "admin-data")
:content (~cart-admin-content)) :content (~payments/admin-content))
(defpage cart-payments (defpage cart-payments
:path "/<page_slug>/admin/payments/" :path "/<page_slug>/admin/payments/"
:auth :admin :auth :admin
:layout (:cart-admin :selected "payments") :layout (:cart-admin :selected "payments")
:data (service "cart-page" "payments-admin-data") :data (service "cart-page" "payments-admin-data")
:content (~cart-payments-content :content (~payments/content
:page-config page-config)) :page-config page-config))

View File

@@ -15,7 +15,7 @@ async def render_orders_page(ctx, orders, page, total_pages, search, search_coun
order_dicts = [_serialize_order(o) for o in orders] order_dicts = [_serialize_order(o) for o in orders]
content = sx_call("orders-list-content", orders=order_dicts, content = sx_call("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix) page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
header_rows = await render_to_sx_with_env("cart-orders-layout-full", {}, header_rows = await render_to_sx_with_env("layouts/orders-layout-full", {},
list_url=list_url, list_url=list_url,
) )
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx)) filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
@@ -47,7 +47,7 @@ async def render_orders_oob(ctx, orders, page, total_pages, search, search_count
order_dicts = [_serialize_order(o) for o in orders] order_dicts = [_serialize_order(o) for o in orders]
content = sx_call("orders-list-content", orders=order_dicts, content = sx_call("orders-list-content", orders=order_dicts,
page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix) page=page, total_pages=total_pages, rows_url=list_url, detail_url_prefix=detail_url_prefix)
oobs = await render_to_sx_with_env("cart-orders-layout-oob", {}, oobs = await render_to_sx_with_env("layouts/orders-layout-oob", {},
list_url=list_url, list_url=list_url,
) )
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx)) filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
@@ -68,7 +68,7 @@ async def render_order_page(ctx, order, calendar_entries, url_for_fn):
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data) main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = sx_call("order-detail-filter-content", order=order_data, filt = sx_call("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token()) list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
header_rows = await render_to_sx_with_env("cart-order-detail-layout-full", {}, header_rows = await render_to_sx_with_env("layouts/order-detail-layout-full", {},
list_url=list_url, detail_url=detail_url, list_url=list_url, detail_url=detail_url,
order_label=f"Order {order.id}", order_label=f"Order {order.id}",
) )
@@ -89,7 +89,7 @@ async def render_order_oob(ctx, order, calendar_entries, url_for_fn):
main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data) main = sx_call("order-detail-content", order=order_data, calendar_entries=cal_data)
filt = sx_call("order-detail-filter-content", order=order_data, filt = sx_call("order-detail-filter-content", order=order_data,
list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token()) list_url=list_url, recheck_url=recheck_url, pay_url=pay_url, csrf=generate_csrf_token())
oobs = await render_to_sx_with_env("cart-order-detail-layout-oob", {}, oobs = await render_to_sx_with_env("layouts/order-detail-layout-oob", {},
detail_url=detail_url, detail_url=detail_url,
order_label=f"Order {order.id}", order_label=f"Order {order.id}",
) )
@@ -100,7 +100,7 @@ async def render_checkout_error_page(ctx, error=None, order=None):
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
from shared.infrastructure.urls import cart_url from shared.infrastructure.urls import cart_url
err_msg = error or "Unexpected error while creating the hosted checkout session." err_msg = error or "Unexpected error while creating the hosted checkout session."
hdr = await render_to_sx_with_env("layout-root-full", {}) hdr = await render_to_sx_with_env("shared:layout/root-full", {})
filt = sx_call("checkout-error-header") filt = sx_call("checkout-error-header")
content = sx_call("cart-checkout-error-from-data", content = sx_call("cart-checkout-error-from-data",
msg=err_msg, order_id=order.id if order else None, msg=err_msg, order_id=order.id if order else None,

View File

@@ -1,6 +1,6 @@
;; Events admin components ;; Events admin components
(defcomp ~events-calendar-admin-panel (&key description-content csrf description) (defcomp ~admin/calendar-admin-panel (&key description-content csrf description)
(section :class "max-w-3xl mx-auto p-4 space-y-10" (section :class "max-w-3xl mx-auto p-4 space-y-10"
(div (div
(h2 :class "text-xl font-semibold" "Calendar configuration") (h2 :class "text-xl font-semibold" "Calendar configuration")
@@ -19,45 +19,45 @@
(div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save")))) (div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save"))))
(hr :class "border-stone-200"))) (hr :class "border-stone-200")))
(defcomp ~events-entry-admin-link (&key href) (defcomp ~admin/entry-admin-link (&key href)
(a :href href :class "inline-flex items-center gap-1 px-2 py-1 text-xs text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded" (a :href href :class "inline-flex items-center gap-1 px-2 py-1 text-xs text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded"
(i :class "fa fa-cog" :aria-hidden "true") " Admin")) (i :class "fa fa-cog" :aria-hidden "true") " Admin"))
(defcomp ~events-entry-field (&key label content) (defcomp ~admin/entry-field (&key label content)
(div :class "flex flex-col mb-4" (div :class "flex flex-col mb-4"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label) (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
content)) content))
(defcomp ~events-entry-name-field (&key name) (defcomp ~admin/entry-name-field (&key name)
(div :class "mt-1 text-lg font-medium" name)) (div :class "mt-1 text-lg font-medium" name))
(defcomp ~events-entry-slot-assigned (&key slot-name flex-label) (defcomp ~admin/entry-slot-assigned (&key slot-name flex-label)
(div :class "mt-1" (div :class "mt-1"
(span :class "px-2 py-1 rounded text-sm bg-blue-100 text-blue-700" slot-name) (span :class "px-2 py-1 rounded text-sm bg-blue-100 text-blue-700" slot-name)
(span :class "ml-2 text-xs text-stone-500" flex-label))) (span :class "ml-2 text-xs text-stone-500" flex-label)))
(defcomp ~events-entry-slot-none () (defcomp ~admin/entry-slot-none ()
(div :class "mt-1" (span :class "text-sm text-stone-400" "No slot assigned"))) (div :class "mt-1" (span :class "text-sm text-stone-400" "No slot assigned")))
(defcomp ~events-entry-time-field (&key time-str) (defcomp ~admin/entry-time-field (&key time-str)
(div :class "mt-1" time-str)) (div :class "mt-1" time-str))
(defcomp ~events-entry-state-field (&key entry-id badge) (defcomp ~admin/entry-state-field (&key entry-id badge)
(div :class "mt-1" (div :id (str "entry-state-" entry-id) badge))) (div :class "mt-1" (div :id (str "entry-state-" entry-id) badge)))
(defcomp ~events-entry-cost-field (&key cost) (defcomp ~admin/entry-cost-field (&key cost)
(div :class "mt-1" (span :class "font-medium text-green-600" cost))) (div :class "mt-1" (span :class "font-medium text-green-600" cost)))
(defcomp ~events-entry-tickets-field (&key entry-id tickets-config) (defcomp ~admin/entry-tickets-field (&key entry-id tickets-config)
(div :class "mt-1" :id (str "entry-tickets-" entry-id) tickets-config)) (div :class "mt-1" :id (str "entry-tickets-" entry-id) tickets-config))
(defcomp ~events-entry-date-field (&key date-str) (defcomp ~admin/entry-date-field (&key date-str)
(div :class "mt-1" date-str)) (div :class "mt-1" date-str))
(defcomp ~events-entry-posts-field (&key entry-id posts-panel) (defcomp ~admin/entry-posts-field (&key entry-id posts-panel)
(div :class "mt-1" :id (str "entry-posts-" entry-id) posts-panel)) (div :class "mt-1" :id (str "entry-posts-" entry-id) posts-panel))
(defcomp ~events-entry-panel (&key entry-id list-container name slot time state cost (defcomp ~admin/entry-panel (&key entry-id list-container name slot time state cost
tickets buy date posts options pre-action edit-url) tickets buy date posts options pre-action edit-url)
(section :id (str "entry-" entry-id) :class list-container (section :id (str "entry-" entry-id) :class list-container
name slot time state cost name slot time state cost
@@ -68,21 +68,21 @@
:sx-get edit-url :sx-target (str "#entry-" entry-id) :sx-swap "outerHTML" :sx-get edit-url :sx-target (str "#entry-" entry-id) :sx-swap "outerHTML"
"Edit")))) "Edit"))))
(defcomp ~events-entry-title (&key name badge) (defcomp ~admin/entry-title (&key name badge)
(<> (i :class "fa fa-clock") " " name " " badge)) (<> (i :class "fa fa-clock") " " name " " badge))
(defcomp ~events-entry-times (&key time-str) (defcomp ~admin/entry-times (&key time-str)
(div :class "text-sm text-gray-600" time-str)) (div :class "text-sm text-gray-600" time-str))
(defcomp ~events-entry-optioned-oob (&key entry-id title state) (defcomp ~admin/entry-optioned-oob (&key entry-id title state)
(<> (div :id (str "entry-title-" entry-id) :sx-swap-oob "innerHTML" title) (<> (div :id (str "entry-title-" entry-id) :sx-swap-oob "innerHTML" title)
(div :id (str "entry-state-" entry-id) :sx-swap-oob "innerHTML" state))) (div :id (str "entry-state-" entry-id) :sx-swap-oob "innerHTML" state)))
(defcomp ~events-entry-options (&key entry-id buttons) (defcomp ~admin/entry-options (&key entry-id buttons)
(div :id (str "calendar_entry_options_" entry-id) :class "flex flex-col md:flex-row gap-1" (div :id (str "calendar_entry_options_" entry-id) :class "flex flex-col md:flex-row gap-1"
buttons)) buttons))
(defcomp ~events-entry-option-button (&key url target csrf btn-type action-btn confirm-title confirm-text (defcomp ~admin/entry-option-button (&key url target csrf btn-type action-btn confirm-title confirm-text
label is-btn) label is-btn)
(form :sx-post url :sx-select target :sx-target target :sx-swap "outerHTML" (form :sx-post url :sx-select target :sx-target target :sx-swap "outerHTML"
:sx-trigger (if is-btn "confirmed" nil) :sx-trigger (if is-btn "confirmed" nil)

View File

@@ -1,34 +1,34 @@
;; Events calendar components ;; Events calendar components
(defcomp ~events-calendar-nav-arrow (&key (pill-cls :as string) (href :as string) (label :as string)) (defcomp ~calendar/nav-arrow (&key (pill-cls :as string) (href :as string) (label :as string))
(a :class (str pill-cls " text-xl") :href href (a :class (str pill-cls " text-xl") :href href
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" label)) :sx-get href :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" label))
(defcomp ~events-calendar-month-label (&key (month-name :as string) (year :as string)) (defcomp ~calendar/month-label (&key (month-name :as string) (year :as string))
(div :class "px-3 font-medium" (str month-name " " year))) (div :class "px-3 font-medium" (str month-name " " year)))
(defcomp ~events-calendar-weekday (&key (name :as string)) (defcomp ~calendar/weekday (&key (name :as string))
(div :class "py-1" name)) (div :class "py-1" name))
(defcomp ~events-calendar-day-short (&key (day-str :as string)) (defcomp ~calendar/day-short (&key (day-str :as string))
(span :class "sm:hidden text-[16px] text-stone-500" day-str)) (span :class "sm:hidden text-[16px] text-stone-500" day-str))
(defcomp ~events-calendar-day-num (&key (pill-cls :as string) (href :as string) (num :as string)) (defcomp ~calendar/day-num (&key (pill-cls :as string) (href :as string) (num :as string))
(a :class pill-cls :href href :sx-get href :sx-target "#main-panel" :sx-select "#main-panel" (a :class pill-cls :href href :sx-get href :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true" num)) :sx-swap "outerHTML" :sx-push-url "true" num))
(defcomp ~events-calendar-entry-badge (&key (bg-cls :as string) (name :as string) (state-label :as string)) (defcomp ~calendar/entry-badge (&key (bg-cls :as string) (name :as string) (state-label :as string))
(div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bg-cls) (div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bg-cls)
(span :class "truncate" name) (span :class "truncate" name)
(span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" state-label))) (span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" state-label)))
(defcomp ~events-calendar-cell (&key (cell-cls :as string) day-short day-num badges) (defcomp ~calendar/cell (&key (cell-cls :as string) day-short day-num badges)
(div :class cell-cls (div :class cell-cls
(div :class "flex justify-between items-center" (div :class "flex justify-between items-center"
(div :class "flex flex-col" day-short day-num)) (div :class "flex flex-col" day-short day-num))
(div :class "mt-1 space-y-0.5" badges))) (div :class "mt-1 space-y-0.5" badges)))
(defcomp ~events-calendar-grid (&key arrows weekdays cells) (defcomp ~calendar/grid (&key arrows weekdays cells)
(section :class "bg-orange-100" (section :class "bg-orange-100"
(header :class "flex items-center justify-center mt-2" (header :class "flex items-center justify-center mt-2"
(nav :class "flex items-center gap-2 text-2xl" arrows)) (nav :class "flex items-center gap-2 text-2xl" arrows))
@@ -37,36 +37,36 @@
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" cells)))) (div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" cells))))
;; Calendar grid from data — all iteration in sx ;; Calendar grid from data — all iteration in sx
(defcomp ~events-calendar-grid-from-data (&key (pill-cls :as string) (month-name :as string) (year :as string) (defcomp ~calendar/grid-from-data (&key (pill-cls :as string) (month-name :as string) (year :as string)
(prev-year-href :as string) (prev-month-href :as string) (prev-year-href :as string) (prev-month-href :as string)
(next-month-href :as string) (next-year-href :as string) (next-month-href :as string) (next-year-href :as string)
(weekday-names :as list) (cells :as list)) (weekday-names :as list) (cells :as list))
(~events-calendar-grid (~calendar/grid
:arrows (<> :arrows (<>
(~events-calendar-nav-arrow :pill-cls pill-cls :href prev-year-href :label "\u00ab") (~calendar/nav-arrow :pill-cls pill-cls :href prev-year-href :label "\u00ab")
(~events-calendar-nav-arrow :pill-cls pill-cls :href prev-month-href :label "\u2039") (~calendar/nav-arrow :pill-cls pill-cls :href prev-month-href :label "\u2039")
(~events-calendar-month-label :month-name month-name :year year) (~calendar/month-label :month-name month-name :year year)
(~events-calendar-nav-arrow :pill-cls pill-cls :href next-month-href :label "\u203a") (~calendar/nav-arrow :pill-cls pill-cls :href next-month-href :label "\u203a")
(~events-calendar-nav-arrow :pill-cls pill-cls :href next-year-href :label "\u00bb")) (~calendar/nav-arrow :pill-cls pill-cls :href next-year-href :label "\u00bb"))
:weekdays (<> (map (lambda (wd) (~events-calendar-weekday :name wd)) :weekdays (<> (map (lambda (wd) (~calendar/weekday :name wd))
(or weekday-names (list)))) (or weekday-names (list))))
:cells (<> (map (lambda (cell) :cells (<> (map (lambda (cell)
(~events-calendar-cell (~calendar/cell
:cell-cls (get cell "cell-cls") :cell-cls (get cell "cell-cls")
:day-short (when (get cell "day-str") :day-short (when (get cell "day-str")
(~events-calendar-day-short :day-str (get cell "day-str"))) (~calendar/day-short :day-str (get cell "day-str")))
:day-num (when (get cell "day-href") :day-num (when (get cell "day-href")
(~events-calendar-day-num :pill-cls pill-cls (~calendar/day-num :pill-cls pill-cls
:href (get cell "day-href") :num (get cell "day-num"))) :href (get cell "day-href") :num (get cell "day-num")))
:badges (when (get cell "badges") :badges (when (get cell "badges")
(<> (map (lambda (b) (<> (map (lambda (b)
(~events-calendar-entry-badge (~calendar/entry-badge
:bg-cls (get b "bg-cls") :name (get b "name") :bg-cls (get b "bg-cls") :name (get b "name")
:state-label (get b "state-label"))) :state-label (get b "state-label")))
(get cell "badges")))))) (get cell "badges"))))))
(or cells (list)))))) (or cells (list))))))
(defcomp ~events-calendar-description-display (&key (description :as string?) (edit-url :as string)) (defcomp ~calendar/description-display (&key (description :as string?) (edit-url :as string))
(div :id "calendar-description" (div :id "calendar-description"
(if description (if description
(p :class "text-stone-700 whitespace-pre-line break-all" description) (p :class "text-stone-700 whitespace-pre-line break-all" description)
@@ -75,12 +75,12 @@
:sx-get edit-url :sx-target "#calendar-description" :sx-swap "outerHTML" :sx-get edit-url :sx-target "#calendar-description" :sx-swap "outerHTML"
(i :class "fas fa-edit")))) (i :class "fas fa-edit"))))
(defcomp ~events-calendar-description-title-oob (&key (description :as string)) (defcomp ~calendar/description-title-oob (&key (description :as string))
(div :id "calendar-description-title" :sx-swap-oob "outerHTML" (div :id "calendar-description-title" :sx-swap-oob "outerHTML"
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block" :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
description)) description))
(defcomp ~events-calendar-description-edit-form (&key (save-url :as string) (cancel-url :as string) (csrf :as string) (description :as string?)) (defcomp ~calendar/description-edit-form (&key (save-url :as string) (cancel-url :as string) (csrf :as string) (description :as string?))
(div :id "calendar-description" (div :id "calendar-description"
(form :sx-post save-url :sx-target "#calendar-description" :sx-swap "outerHTML" (form :sx-post save-url :sx-target "#calendar-description" :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)

View File

@@ -1,18 +1,18 @@
;; Events day components ;; Events day components
(defcomp ~events-day-entry-link (&key (href :as string) (name :as string) (time-str :as string)) (defcomp ~day/entry-link (&key (href :as string) (name :as string) (time-str :as string))
(a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0" (a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name) (div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str)))) (div :class "text-xs text-stone-600 truncate" time-str))))
(defcomp ~events-day-entries-nav (&key inner) (defcomp ~day/entries-nav (&key inner)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "day-entries-nav-wrapper" :id "day-entries-nav-wrapper"
(div :class "flex overflow-x-auto gap-1 scrollbar-thin" (div :class "flex overflow-x-auto gap-1 scrollbar-thin"
inner))) inner)))
(defcomp ~events-day-table (&key (list-container :as string) rows (pre-action :as string) (add-url :as string)) (defcomp ~day/table (&key (list-container :as string) rows (pre-action :as string) (add-url :as string))
(section :id "day-entries" :class list-container (section :id "day-entries" :class list-container
(table :class "w-full text-sm border table-fixed" (table :class "w-full text-sm border table-fixed"
(thead :class "bg-stone-100" (thead :class "bg-stone-100"
@@ -29,95 +29,95 @@
:sx-get add-url :sx-target "#entry-add-container" :sx-swap "innerHTML" :sx-get add-url :sx-target "#entry-add-container" :sx-swap "innerHTML"
"+ Add entry")))) "+ Add entry"))))
(defcomp ~events-day-empty-row () (defcomp ~day/empty-row ()
(tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet."))) (tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet.")))
(defcomp ~events-day-row-name (&key (href :as string) (pill-cls :as string) (name :as string)) (defcomp ~day/row-name (&key (href :as string) (pill-cls :as string) (name :as string))
(td :class "p-2 align-top w-2/6" (div :class "font-medium" (td :class "p-2 align-top w-2/6" (div :class "font-medium"
(a :href href :class pill-cls :sx-get href :sx-target "#main-panel" :sx-select "#main-panel" (a :href href :class pill-cls :sx-get href :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true" name)))) :sx-swap "outerHTML" :sx-push-url "true" name))))
(defcomp ~events-day-row-slot (&key (href :as string) (pill-cls :as string) (slot-name :as string) (time-str :as string)) (defcomp ~day/row-slot (&key (href :as string) (pill-cls :as string) (slot-name :as string) (time-str :as string))
(td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium" (td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium"
(a :href href :class pill-cls :sx-get href :sx-target "#main-panel" :sx-select "#main-panel" (a :href href :class pill-cls :sx-get href :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true" slot-name) :sx-swap "outerHTML" :sx-push-url "true" slot-name)
(span :class "text-stone-600 font-normal" time-str)))) (span :class "text-stone-600 font-normal" time-str))))
(defcomp ~events-day-row-time (&key (start :as string) (end :as string)) (defcomp ~day/row-time (&key (start :as string) (end :as string))
(td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str start end)))) (td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str start end))))
(defcomp ~events-day-row-state (&key (state-id :as string) badge) (defcomp ~day/row-state (&key (state-id :as string) badge)
(td :class "p-2 align-top w-1/6" (div :id state-id badge))) (td :class "p-2 align-top w-1/6" (div :id state-id badge)))
(defcomp ~events-day-row-cost (&key (cost-str :as string)) (defcomp ~day/row-cost (&key (cost-str :as string))
(td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" cost-str))) (td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" cost-str)))
(defcomp ~events-day-row-tickets (&key (price-str :as string) (count-str :as string)) (defcomp ~day/row-tickets (&key (price-str :as string) (count-str :as string))
(td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1" (td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1"
(div :class "font-medium text-green-600" price-str) (div :class "font-medium text-green-600" price-str)
(div :class "text-stone-600" count-str)))) (div :class "text-stone-600" count-str))))
(defcomp ~events-day-row-no-tickets () (defcomp ~day/row-no-tickets ()
(td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets"))) (td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets")))
(defcomp ~events-day-row-actions () (defcomp ~day/row-actions ()
(td :class "p-2 align-top w-1/6")) (td :class "p-2 align-top w-1/6"))
(defcomp ~events-day-row (&key (tr-cls :as string) name slot state cost tickets actions) (defcomp ~day/row (&key (tr-cls :as string) name slot state cost tickets actions)
(tr :class tr-cls name slot state cost tickets actions)) (tr :class tr-cls name slot state cost tickets actions))
(defcomp ~events-day-admin-panel () (defcomp ~day/admin-panel ()
(div :class "p-4 text-sm text-stone-500" "Admin options")) (div :class "p-4 text-sm text-stone-500" "Admin options"))
(defcomp ~events-day-entries-nav-oob-empty () (defcomp ~day/entries-nav-oob-empty ()
(div :id "day-entries-nav-wrapper" :sx-swap-oob "true")) (div :id "day-entries-nav-wrapper" :sx-swap-oob "true"))
(defcomp ~events-day-entries-nav-oob (&key items) (defcomp ~day/entries-nav-oob (&key items)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "day-entries-nav-wrapper" :sx-swap-oob "true" :id "day-entries-nav-wrapper" :sx-swap-oob "true"
(div :class "flex overflow-x-auto gap-1 scrollbar-thin" items))) (div :class "flex overflow-x-auto gap-1 scrollbar-thin" items)))
(defcomp ~events-day-nav-entry (&key (href :as string) (nav-btn :as string) (name :as string) (time-str :as string)) (defcomp ~day/nav-entry (&key (href :as string) (nav-btn :as string) (name :as string) (time-str :as string))
(a :href href :class nav-btn (a :href href :class nav-btn
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name) (div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str)))) (div :class "text-xs text-stone-600 truncate" time-str))))
;; Day table from data — all row iteration in sx ;; Day table from data — all row iteration in sx
(defcomp ~events-day-table-from-data (&key (list-container :as string) (pre-action :as string) (add-url :as string) (tr-cls :as string) (pill-cls :as string) (rows :as list?)) (defcomp ~day/table-from-data (&key (list-container :as string) (pre-action :as string) (add-url :as string) (tr-cls :as string) (pill-cls :as string) (rows :as list?))
(~events-day-table (~day/table
:list-container list-container :list-container list-container
:rows (if (empty? (or rows (list))) :rows (if (empty? (or rows (list)))
(~events-day-empty-row) (~day/empty-row)
(<> (map (lambda (r) (<> (map (lambda (r)
(~events-day-row (~day/row
:tr-cls tr-cls :tr-cls tr-cls
:name (~events-day-row-name :name (~day/row-name
:href (get r "href") :pill-cls pill-cls :name (get r "name")) :href (get r "href") :pill-cls pill-cls :name (get r "name"))
:slot (if (get r "slot-name") :slot (if (get r "slot-name")
(~events-day-row-slot (~day/row-slot
:href (get r "slot-href") :pill-cls pill-cls :href (get r "slot-href") :pill-cls pill-cls
:slot-name (get r "slot-name") :time-str (get r "slot-time")) :slot-name (get r "slot-name") :time-str (get r "slot-time"))
(~events-day-row-time :start (get r "start") :end (get r "end"))) (~day/row-time :start (get r "start") :end (get r "end")))
:state (~events-day-row-state :state (~day/row-state
:state-id (get r "state-id") :state-id (get r "state-id")
:badge (~entry-state-badge :state (get r "state"))) :badge (~entries/entry-state-badge :state (get r "state")))
:cost (~events-day-row-cost :cost-str (get r "cost-str")) :cost (~day/row-cost :cost-str (get r "cost-str"))
:tickets (if (get r "has-tickets") :tickets (if (get r "has-tickets")
(~events-day-row-tickets (~day/row-tickets
:price-str (get r "price-str") :count-str (get r "count-str")) :price-str (get r "price-str") :count-str (get r "count-str"))
(~events-day-row-no-tickets)) (~day/row-no-tickets))
:actions (~events-day-row-actions))) :actions (~day/row-actions)))
(or rows (list))))) (or rows (list)))))
:pre-action pre-action :add-url add-url)) :pre-action pre-action :add-url add-url))
;; Day entries nav OOB from data ;; Day entries nav OOB from data
(defcomp ~events-day-entries-nav-oob-from-data (&key (nav-btn :as string) (entries :as list?)) (defcomp ~day/entries-nav-oob-from-data (&key (nav-btn :as string) (entries :as list?))
(if (empty? (or entries (list))) (if (empty? (or entries (list)))
(~events-day-entries-nav-oob-empty) (~day/entries-nav-oob-empty)
(~events-day-entries-nav-oob (~day/entries-nav-oob
:items (<> (map (lambda (e) :items (<> (map (lambda (e)
(~events-day-nav-entry (~day/nav-entry
:href (get e "href") :nav-btn nav-btn :href (get e "href") :nav-btn nav-btn
:name (get e "name") :time-str (get e "time-str"))) :name (get e "name") :time-str (get e "time-str")))
entries))))) entries)))))

View File

@@ -4,8 +4,8 @@
;; State badges — cond maps state string to class + label ;; State badges — cond maps state string to class + label
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~entry-state-badge (&key state) (defcomp ~entries/entry-state-badge (&key state)
(~badge (~shared:misc/badge
:cls (cond :cls (cond
((= state "confirmed") "bg-emerald-100 text-emerald-800") ((= state "confirmed") "bg-emerald-100 text-emerald-800")
((= state "provisional") "bg-amber-100 text-amber-800") ((= state "provisional") "bg-amber-100 text-amber-800")
@@ -21,7 +21,7 @@
((= state "declined") "Declined") ((= state "declined") "Declined")
(true (or state "Unknown"))))) (true (or state "Unknown")))))
(defcomp ~entry-state-badge-lg (&key state) (defcomp ~entries/entry-state-badge-lg (&key state)
(span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium "
(cond (cond
((= state "confirmed") "bg-emerald-100 text-emerald-800") ((= state "confirmed") "bg-emerald-100 text-emerald-800")
@@ -38,8 +38,8 @@
((= state "declined") "Declined") ((= state "declined") "Declined")
(true (or state "Unknown"))))) (true (or state "Unknown")))))
(defcomp ~ticket-state-badge (&key state) (defcomp ~entries/ticket-state-badge (&key state)
(~badge (~shared:misc/badge
:cls (cond :cls (cond
((= state "confirmed") "bg-emerald-100 text-emerald-800") ((= state "confirmed") "bg-emerald-100 text-emerald-800")
((= state "checked_in") "bg-blue-100 text-blue-800") ((= state "checked_in") "bg-blue-100 text-blue-800")
@@ -53,7 +53,7 @@
((= state "cancelled") "Cancelled") ((= state "cancelled") "Cancelled")
(true (or state "Unknown"))))) (true (or state "Unknown")))))
(defcomp ~ticket-state-badge-lg (&key state) (defcomp ~entries/ticket-state-badge-lg (&key state)
(span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium "
(cond (cond
((= state "confirmed") "bg-emerald-100 text-emerald-800") ((= state "confirmed") "bg-emerald-100 text-emerald-800")
@@ -73,36 +73,36 @@
;; Entry card components ;; Entry card components
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-entry-title-linked (&key href name) (defcomp ~entries/entry-title-linked (&key href name)
(a :href href :class "hover:text-emerald-700" (a :href href :class "hover:text-emerald-700"
(h2 :class "text-lg font-semibold text-stone-900" name))) (h2 :class "text-lg font-semibold text-stone-900" name)))
(defcomp ~events-entry-title-plain (&key name) (defcomp ~entries/entry-title-plain (&key name)
(h2 :class "text-lg font-semibold text-stone-900" name)) (h2 :class "text-lg font-semibold text-stone-900" name))
(defcomp ~events-entry-title-tile-linked (&key href name) (defcomp ~entries/entry-title-tile-linked (&key href name)
(a :href href :class "hover:text-emerald-700" (a :href href :class "hover:text-emerald-700"
(h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name))) (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name)))
(defcomp ~events-entry-title-tile-plain (&key name) (defcomp ~entries/entry-title-tile-plain (&key name)
(h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name)) (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name))
(defcomp ~events-entry-page-badge (&key href title) (defcomp ~entries/entry-page-badge (&key href title)
(a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" title)) (a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" title))
(defcomp ~events-entry-cal-badge (&key name) (defcomp ~entries/entry-cal-badge (&key name)
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" name)) (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" name))
(defcomp ~events-entry-time-linked (&key href date-str) (defcomp ~entries/entry-time-linked (&key href date-str)
(<> (a :href href :class "hover:text-stone-700" date-str) " · ")) (<> (a :href href :class "hover:text-stone-700" date-str) " · "))
(defcomp ~events-entry-time-plain (&key date-str) (defcomp ~entries/entry-time-plain (&key date-str)
(<> (span date-str) " · ")) (<> (span date-str) " · "))
(defcomp ~events-entry-cost (&key cost) (defcomp ~entries/entry-cost (&key cost)
(div :class "mt-1 text-sm font-medium text-green-600" cost)) (div :class "mt-1 text-sm font-medium text-green-600" cost))
(defcomp ~events-entry-card (&key title badges time-parts cost widget) (defcomp ~entries/entry-card (&key title badges time-parts cost widget)
(article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-4" (article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-4"
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-3" (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-3"
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
@@ -112,7 +112,7 @@
cost) cost)
widget))) widget)))
(defcomp ~events-entry-card-tile (&key title badges time cost widget) (defcomp ~entries/entry-card-tile (&key title badges time cost widget)
(article :class "rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden" (article :class "rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden"
(div :class "p-3" (div :class "p-3"
title title
@@ -121,20 +121,20 @@
cost) cost)
widget)) widget))
(defcomp ~events-entry-tile-widget-wrapper (&key widget) (defcomp ~entries/entry-tile-widget-wrapper (&key widget)
(div :class "border-t border-stone-100 px-3 py-2" widget)) (div :class "border-t border-stone-100 px-3 py-2" widget))
(defcomp ~events-entry-widget-wrapper (&key widget) (defcomp ~entries/entry-widget-wrapper (&key widget)
(div :class "shrink-0" widget)) (div :class "shrink-0" widget))
(defcomp ~events-date-separator (&key date-str) (defcomp ~entries/date-separator (&key date-str)
(div :class "pt-2 pb-1" (div :class "pt-2 pb-1"
(h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" date-str))) (h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" date-str)))
(defcomp ~events-grid (&key grid-cls cards) (defcomp ~entries/grid (&key grid-cls cards)
(div :class grid-cls cards)) (div :class grid-cls cards))
(defcomp ~events-main-panel-body (&key toggle body) (defcomp ~entries/main-panel-body (&key toggle body)
(<> toggle body (div :class "pb-8"))) (<> toggle body (div :class "pb-8")))
@@ -143,46 +143,46 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Ticket widget from data — replaces _ticket_widget_html Python composition ;; Ticket widget from data — replaces _ticket_widget_html Python composition
(defcomp ~events-tw-widget-from-data (&key entry-id price qty ticket-url csrf) (defcomp ~entries/tw-widget-from-data (&key entry-id price qty ticket-url csrf)
(~events-tw-widget :entry-id (str entry-id) :price price (~page/tw-widget :entry-id (str entry-id) :price price
:inner (if (= (or qty 0) 0) :inner (if (= (or qty 0) 0)
(~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id) (~page/tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
:csrf csrf :entry-id (str entry-id) :count-val "1" :csrf csrf :entry-id (str entry-id) :count-val "1"
:btn (~events-tw-cart-plus)) :btn (~page/tw-cart-plus))
(<> (<>
(~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id) (~page/tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
:csrf csrf :entry-id (str entry-id) :count-val (str (- qty 1)) :csrf csrf :entry-id (str entry-id) :count-val (str (- qty 1))
:btn (~events-tw-minus)) :btn (~page/tw-minus))
(~events-tw-cart-icon :qty (str qty)) (~page/tw-cart-icon :qty (str qty))
(~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id) (~page/tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
:csrf csrf :entry-id (str entry-id) :count-val (str (+ qty 1)) :csrf csrf :entry-id (str entry-id) :count-val (str (+ qty 1))
:btn (~events-tw-plus)))))) :btn (~page/tw-plus))))))
;; Entry card (list view) from data ;; Entry card (list view) from data
(defcomp ~events-entry-card-from-data (&key entry-href name day-href (defcomp ~entries/entry-card-from-data (&key entry-href name day-href
page-badge-href page-badge-title cal-name page-badge-href page-badge-title cal-name
date-str start-time end-time is-page-scoped date-str start-time end-time is-page-scoped
cost has-ticket ticket-data) cost has-ticket ticket-data)
(~events-entry-card (~entries/entry-card
:title (if entry-href :title (if entry-href
(~events-entry-title-linked :href entry-href :name name) (~entries/entry-title-linked :href entry-href :name name)
(~events-entry-title-plain :name name)) (~entries/entry-title-plain :name name))
:badges (<> :badges (<>
(when page-badge-title (when page-badge-title
(~events-entry-page-badge :href page-badge-href :title page-badge-title)) (~entries/entry-page-badge :href page-badge-href :title page-badge-title))
(when cal-name (when cal-name
(~events-entry-cal-badge :name cal-name))) (~entries/entry-cal-badge :name cal-name)))
:time-parts (<> :time-parts (<>
(when (and day-href (not is-page-scoped)) (when (and day-href (not is-page-scoped))
(~events-entry-time-linked :href day-href :date-str date-str)) (~entries/entry-time-linked :href day-href :date-str date-str))
(when (and (not day-href) (not is-page-scoped) date-str) (when (and (not day-href) (not is-page-scoped) date-str)
(~events-entry-time-plain :date-str date-str)) (~entries/entry-time-plain :date-str date-str))
start-time start-time
(when end-time (str " \u2013 " end-time))) (when end-time (str " \u2013 " end-time)))
:cost (when cost (~events-entry-cost :cost cost)) :cost (when cost (~entries/entry-cost :cost cost))
:widget (when has-ticket :widget (when has-ticket
(~events-entry-widget-wrapper (~entries/entry-widget-wrapper
:widget (~events-tw-widget-from-data :widget (~entries/tw-widget-from-data
:entry-id (get ticket-data "entry-id") :entry-id (get ticket-data "entry-id")
:price (get ticket-data "price") :price (get ticket-data "price")
:qty (get ticket-data "qty") :qty (get ticket-data "qty")
@@ -190,24 +190,24 @@
:csrf (get ticket-data "csrf")))))) :csrf (get ticket-data "csrf"))))))
;; Entry card (tile view) from data ;; Entry card (tile view) from data
(defcomp ~events-entry-card-tile-from-data (&key entry-href name day-href (defcomp ~entries/entry-card-tile-from-data (&key entry-href name day-href
page-badge-href page-badge-title cal-name page-badge-href page-badge-title cal-name
date-str time-str date-str time-str
cost has-ticket ticket-data) cost has-ticket ticket-data)
(~events-entry-card-tile (~entries/entry-card-tile
:title (if entry-href :title (if entry-href
(~events-entry-title-tile-linked :href entry-href :name name) (~entries/entry-title-tile-linked :href entry-href :name name)
(~events-entry-title-tile-plain :name name)) (~entries/entry-title-tile-plain :name name))
:badges (<> :badges (<>
(when page-badge-title (when page-badge-title
(~events-entry-page-badge :href page-badge-href :title page-badge-title)) (~entries/entry-page-badge :href page-badge-href :title page-badge-title))
(when cal-name (when cal-name
(~events-entry-cal-badge :name cal-name))) (~entries/entry-cal-badge :name cal-name)))
:time time-str :time time-str
:cost (when cost (~events-entry-cost :cost cost)) :cost (when cost (~entries/entry-cost :cost cost))
:widget (when has-ticket :widget (when has-ticket
(~events-entry-tile-widget-wrapper (~entries/entry-tile-widget-wrapper
:widget (~events-tw-widget-from-data :widget (~entries/tw-widget-from-data
:entry-id (get ticket-data "entry-id") :entry-id (get ticket-data "entry-id")
:price (get ticket-data "price") :price (get ticket-data "price")
:qty (get ticket-data "qty") :qty (get ticket-data "qty")
@@ -215,13 +215,13 @@
:csrf (get ticket-data "csrf")))))) :csrf (get ticket-data "csrf"))))))
;; Entry cards list (with date separators + sentinel) from data ;; Entry cards list (with date separators + sentinel) from data
(defcomp ~events-entry-cards-from-data (&key items view page has-more next-url) (defcomp ~entries/entry-cards-from-data (&key items view page has-more next-url)
(<> (<>
(map (lambda (item) (map (lambda (item)
(if (get item "is-separator") (if (get item "is-separator")
(~events-date-separator :date-str (get item "date-str")) (~entries/date-separator :date-str (get item "date-str"))
(if (= view "tile") (if (= view "tile")
(~events-entry-card-tile-from-data (~entries/entry-card-tile-from-data
:entry-href (get item "entry-href") :name (get item "name") :entry-href (get item "entry-href") :name (get item "name")
:day-href (get item "day-href") :day-href (get item "day-href")
:page-badge-href (get item "page-badge-href") :page-badge-href (get item "page-badge-href")
@@ -230,7 +230,7 @@
:date-str (get item "date-str") :time-str (get item "time-str") :date-str (get item "date-str") :time-str (get item "time-str")
:cost (get item "cost") :has-ticket (get item "has-ticket") :cost (get item "cost") :has-ticket (get item "has-ticket")
:ticket-data (get item "ticket-data")) :ticket-data (get item "ticket-data"))
(~events-entry-card-from-data (~entries/entry-card-from-data
:entry-href (get item "entry-href") :name (get item "name") :entry-href (get item "entry-href") :name (get item "name")
:day-href (get item "day-href") :day-href (get item "day-href")
:page-badge-href (get item "page-badge-href") :page-badge-href (get item "page-badge-href")
@@ -243,20 +243,20 @@
:ticket-data (get item "ticket-data"))))) :ticket-data (get item "ticket-data")))))
(or items (list))) (or items (list)))
(when has-more (when has-more
(~sentinel-simple :id (str "sentinel-" page) :next-url next-url)))) (~shared:misc/sentinel-simple :id (str "sentinel-" page) :next-url next-url))))
;; Events main panel (toggle + cards grid) from data ;; Events main panel (toggle + cards grid) from data
(defcomp ~events-main-panel-from-data (&key toggle items view page has-more next-url) (defcomp ~entries/main-panel-from-data (&key toggle items view page has-more next-url)
(~events-main-panel-body (~entries/main-panel-body
:toggle toggle :toggle toggle
:body (if items :body (if items
(~events-grid (~entries/grid
:grid-cls (if (= view "tile") :grid-cls (if (= view "tile")
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
"max-w-full px-3 py-3 space-y-3") "max-w-full px-3 py-3 space-y-3")
:cards (~events-entry-cards-from-data :cards (~entries/entry-cards-from-data
:items items :view view :page page :items items :view view :page page
:has-more has-more :next-url next-url)) :has-more has-more :next-url next-url))
(~empty-state :icon "fa fa-calendar-xmark" (~shared:misc/empty-state :icon "fa fa-calendar-xmark"
:message "No upcoming events" :message "No upcoming events"
:cls "px-3 py-12 text-center text-stone-400")))) :cls "px-3 py-12 text-center text-stone-400"))))

View File

@@ -5,25 +5,25 @@
;; Slot picker option (shared by entry-edit and entry-add) ;; Slot picker option (shared by entry-edit and entry-add)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-slot-option (&key value data-start data-end data-flexible data-cost selected label) (defcomp ~forms/slot-option (&key value data-start data-end data-flexible data-cost selected label)
(option :value value :data-start data-start :data-end data-end (option :value value :data-start data-start :data-end data-end
:data-flexible data-flexible :data-cost data-cost :data-flexible data-flexible :data-cost data-cost
:selected selected :selected selected
label)) label))
(defcomp ~events-slot-picker (&key id options) (defcomp ~forms/slot-picker (&key id options)
(select :id id :name "slot_id" :class "w-full border p-2 rounded" (select :id id :name "slot_id" :class "w-full border p-2 rounded"
:data-slot-picker "" :required "required" :data-slot-picker "" :required "required"
options)) options))
(defcomp ~events-no-slots () (defcomp ~forms/no-slots ()
(div :class "text-sm text-stone-500" "No slots defined for this day.")) (div :class "text-sm text-stone-500" "No slots defined for this day."))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Entry edit form (_types/entry/_edit.html) ;; Entry edit form (_types/entry/_edit.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-entry-edit-form (&key entry-id list-container put-url cancel-url csrf (defcomp ~forms/entry-edit-form (&key entry-id list-container put-url cancel-url csrf
name-val slot-picker name-val slot-picker
start-val end-val cost-display start-val end-val cost-display
ticket-price-val ticket-count-val ticket-price-val ticket-count-val
@@ -115,7 +115,7 @@
;; Post search results (_types/entry/_post_search_results.html) ;; Post search results (_types/entry/_post_search_results.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-post-search-item (&key post-url entry-id csrf post-id (defcomp ~forms/post-search-item (&key post-url entry-id csrf post-id
img title) img title)
(form :sx-post post-url :sx-target (str "#entry-posts-" entry-id) :sx-swap "innerHTML" (form :sx-post post-url :sx-target (str "#entry-posts-" entry-id) :sx-swap "innerHTML"
:class "p-2 hover:bg-stone-50 cursor-pointer rounded text-sm border-b" :class "p-2 hover:bg-stone-50 cursor-pointer rounded text-sm border-b"
@@ -129,7 +129,7 @@
:data-confirm-cancel-text "Cancel" :data-confirm-cancel-text "Cancel"
img (span title)))) img (span title))))
(defcomp ~events-post-search-sentinel (&key page next-url) (defcomp ~forms/post-search-sentinel (&key page next-url)
(div :id (str "post-search-sentinel-" page) (div :id (str "post-search-sentinel-" page)
:sx-get next-url :sx-get next-url
:sx-trigger "intersect once delay:250ms, sentinel:retry" :sx-trigger "intersect once delay:250ms, sentinel:retry"
@@ -172,7 +172,7 @@
(div :class "text-xs text-center text-stone-400 js-loading" "Loading more...") (div :class "text-xs text-center text-stone-400 js-loading" "Loading more...")
(div :class "text-xs text-center text-stone-400 js-neterr hidden" "Connection error. Retrying..."))) (div :class "text-xs text-center text-stone-400 js-neterr hidden" "Connection error. Retrying...")))
(defcomp ~events-post-search-end () (defcomp ~forms/post-search-end ()
(div :class "py-2 text-xs text-center text-stone-400" "End of results")) (div :class "py-2 text-xs text-center text-stone-400" "End of results"))
@@ -180,17 +180,17 @@
;; Slot edit form (_types/slot/_edit.html) ;; Slot edit form (_types/slot/_edit.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-day-checkbox (&key name label checked) (defcomp ~forms/day-checkbox (&key name label checked)
(label :class "flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100" (label :class "flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100"
(input :type "checkbox" :name name :value "1" :data-day name :checked checked) (input :type "checkbox" :name name :value "1" :data-day name :checked checked)
(span label))) (span label)))
(defcomp ~events-day-all-checkbox (&key checked) (defcomp ~forms/day-all-checkbox (&key checked)
(label :class "flex items-center gap-1 px-2 py-1 rounded-full bg-slate-200" (label :class "flex items-center gap-1 px-2 py-1 rounded-full bg-slate-200"
(input :type "checkbox" :data-day-all "" :checked checked) (input :type "checkbox" :data-day-all "" :checked checked)
(span "All"))) (span "All")))
(defcomp ~events-slot-edit-form (&key slot-id list-container put-url cancel-url csrf (defcomp ~forms/slot-edit-form (&key slot-id list-container put-url cancel-url csrf
name-val cost-val start-val end-val desc-val name-val cost-val start-val end-val desc-val
days flexible-checked days flexible-checked
action-btn cancel-btn) action-btn cancel-btn)
@@ -271,7 +271,7 @@
;; Slot add form (_types/slots/_add.html) ;; Slot add form (_types/slots/_add.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-slot-add-form (&key post-url csrf days action-btn cancel-btn cancel-url) (defcomp ~forms/slot-add-form (&key post-url csrf days action-btn cancel-btn cancel-url)
(form :sx-post post-url :sx-target "#slots-table" :sx-select "#slots-table" (form :sx-post post-url :sx-target "#slots-table" :sx-select "#slots-table"
:sx-disinherit "sx-select" :sx-swap "outerHTML" :sx-disinherit "sx-select" :sx-swap "outerHTML"
:sx-headers csrf :class "space-y-3" :sx-headers csrf :class "space-y-3"
@@ -312,7 +312,7 @@
:data-confirm-cancel-text "Cancel" :data-confirm-cancel-text "Cancel"
(i :class "fa fa-save") " Save slot")))) (i :class "fa fa-save") " Save slot"))))
(defcomp ~events-slot-add-button (&key pre-action add-url) (defcomp ~forms/slot-add-button (&key pre-action add-url)
(button :type "button" :class pre-action (button :type "button" :class pre-action
:sx-get add-url :sx-target "#slot-add-container" :sx-swap "innerHTML" :sx-get add-url :sx-target "#slot-add-container" :sx-swap "innerHTML"
"+ Add slot")) "+ Add slot"))
@@ -323,20 +323,20 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Day checkboxes from data — replaces Python loop ;; Day checkboxes from data — replaces Python loop
(defcomp ~events-day-checkboxes-from-data (&key days-data all-checked) (defcomp ~forms/day-checkboxes-from-data (&key days-data all-checked)
(<> (<>
(~events-day-all-checkbox :checked (when all-checked "checked")) (~forms/day-all-checkbox :checked (when all-checked "checked"))
(map (lambda (d) (map (lambda (d)
(~events-day-checkbox (~forms/day-checkbox
:name (get d "name") :name (get d "name")
:label (get d "label") :label (get d "label")
:checked (when (get d "checked") "checked"))) :checked (when (get d "checked") "checked")))
(or days-data (list))))) (or days-data (list)))))
;; Slot options from data — replaces _slot_options_html Python loop ;; Slot options from data — replaces _slot_options_html Python loop
(defcomp ~events-slot-options-from-data (&key slots) (defcomp ~forms/slot-options-from-data (&key slots)
(<> (map (lambda (s) (<> (map (lambda (s)
(~events-slot-option (~forms/slot-option
:value (get s "value") :value (get s "value")
:data-start (get s "data-start") :data-start (get s "data-start")
:data-end (get s "data-end") :data-end (get s "data-end")
@@ -347,32 +347,32 @@
(or slots (list))))) (or slots (list)))))
;; Slot picker from data — wraps picker + options ;; Slot picker from data — wraps picker + options
(defcomp ~events-slot-picker-from-data (&key id slots) (defcomp ~forms/slot-picker-from-data (&key id slots)
(if (empty? (or slots (list))) (if (empty? (or slots (list)))
(~events-no-slots) (~forms/no-slots)
(~events-slot-picker (~forms/slot-picker
:id id :id id
:options (~events-slot-options-from-data :slots slots)))) :options (~forms/slot-options-from-data :slots slots))))
;; Slot edit form from data ;; Slot edit form from data
(defcomp ~events-slot-edit-form-from-data (&key slot-id list-container put-url cancel-url csrf (defcomp ~forms/slot-edit-form-from-data (&key slot-id list-container put-url cancel-url csrf
name-val cost-val start-val end-val desc-val name-val cost-val start-val end-val desc-val
days-data all-checked flexible-checked days-data all-checked flexible-checked
action-btn cancel-btn) action-btn cancel-btn)
(~events-slot-edit-form (~forms/slot-edit-form
:slot-id slot-id :list-container list-container :slot-id slot-id :list-container list-container
:put-url put-url :cancel-url cancel-url :csrf csrf :put-url put-url :cancel-url cancel-url :csrf csrf
:name-val name-val :cost-val cost-val :start-val start-val :name-val name-val :cost-val cost-val :start-val start-val
:end-val end-val :desc-val desc-val :end-val end-val :desc-val desc-val
:days (~events-day-checkboxes-from-data :days-data days-data :all-checked all-checked) :days (~forms/day-checkboxes-from-data :days-data days-data :all-checked all-checked)
:flexible-checked flexible-checked :flexible-checked flexible-checked
:action-btn action-btn :cancel-btn cancel-btn)) :action-btn action-btn :cancel-btn cancel-btn))
;; Slot add form from data ;; Slot add form from data
(defcomp ~events-slot-add-form-from-data (&key post-url csrf days-data action-btn cancel-btn cancel-url) (defcomp ~forms/slot-add-form-from-data (&key post-url csrf days-data action-btn cancel-btn cancel-url)
(~events-slot-add-form (~forms/slot-add-form
:post-url post-url :csrf csrf :post-url post-url :csrf csrf
:days (~events-day-checkboxes-from-data :days-data days-data) :days (~forms/day-checkboxes-from-data :days-data days-data)
:action-btn action-btn :cancel-btn cancel-btn :cancel-url cancel-url)) :action-btn action-btn :cancel-btn cancel-btn :cancel-url cancel-url))
@@ -380,7 +380,7 @@
;; Entry add form (_types/day/_add.html) ;; Entry add form (_types/day/_add.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-entry-add-form (&key post-url csrf slot-picker (defcomp ~forms/entry-add-form (&key post-url csrf slot-picker
action-btn cancel-btn cancel-url) action-btn cancel-btn cancel-url)
(<> (<>
(div :id "entry-errors" :class "mt-2 text-sm text-red-600") (div :id "entry-errors" :class "mt-2 text-sm text-red-600")
@@ -446,7 +446,7 @@
:data-confirm-cancel-text "Cancel" :data-confirm-cancel-text "Cancel"
(i :class "fa fa-save") " Save entry"))))) (i :class "fa fa-save") " Save entry")))))
(defcomp ~events-entry-add-button (&key pre-action add-url) (defcomp ~forms/entry-add-button (&key pre-action add-url)
(button :type "button" :class pre-action (button :type "button" :class pre-action
:sx-get add-url :sx-target "#entry-add-container" :sx-swap "innerHTML" :sx-get add-url :sx-target "#entry-add-container" :sx-swap "innerHTML"
"+ Add entry")) "+ Add entry"))
@@ -456,7 +456,7 @@
;; Ticket type edit form (_types/ticket_type/_edit.html) ;; Ticket type edit form (_types/ticket_type/_edit.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-ticket-type-edit-form (&key ticket-id list-container put-url cancel-url csrf (defcomp ~forms/ticket-type-edit-form (&key ticket-id list-container put-url cancel-url csrf
name-val cost-val count-val name-val cost-val count-val
action-btn cancel-btn) action-btn cancel-btn)
(section :id (str "ticket-" ticket-id) :class list-container (section :id (str "ticket-" ticket-id) :class list-container
@@ -509,7 +509,7 @@
;; Ticket type add form (_types/ticket_types/_add.html) ;; Ticket type add form (_types/ticket_types/_add.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-ticket-type-add-form (&key post-url csrf action-btn cancel-btn cancel-url) (defcomp ~forms/ticket-type-add-form (&key post-url csrf action-btn cancel-btn cancel-url)
(form :sx-post post-url :sx-target "#tickets-table" :sx-select "#tickets-table" (form :sx-post post-url :sx-target "#tickets-table" :sx-select "#tickets-table"
:sx-disinherit "sx-select" :sx-swap "outerHTML" :sx-disinherit "sx-select" :sx-swap "outerHTML"
:sx-headers csrf :class "space-y-3" :sx-headers csrf :class "space-y-3"
@@ -540,7 +540,7 @@
:data-confirm-cancel-text "Cancel" :data-confirm-cancel-text "Cancel"
(i :class "fa fa-save") " Save ticket type")))) (i :class "fa fa-save") " Save ticket type"))))
(defcomp ~events-ticket-type-add-button (&key action-btn add-url) (defcomp ~forms/ticket-type-add-button (&key action-btn add-url)
(button :class action-btn (button :class action-btn
:sx-get add-url :sx-target "#ticket-add-container" :sx-swap "innerHTML" :sx-get add-url :sx-target "#ticket-add-container" :sx-swap "innerHTML"
(i :class "fa fa-plus") " Add ticket type")) (i :class "fa fa-plus") " Add ticket type"))
@@ -550,6 +550,6 @@
;; Entry admin nav — placeholder ;; Entry admin nav — placeholder
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-admin-placeholder-nav () (defcomp ~forms/admin-placeholder-nav ()
(div :class "relative nav-group" (div :class "relative nav-group"
(span :class "block px-3 py-2 text-stone-400 text-sm italic" "Admin options"))) (span :class "block px-3 py-2 text-stone-400 text-sm italic" "Admin options")))

View File

@@ -5,14 +5,14 @@
;; Container cards entries (fragments/container_cards_entries.html) ;; Container cards entries (fragments/container_cards_entries.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-frag-entry-card (&key href name date-str time-str) (defcomp ~fragments/frag-entry-card (&key href name date-str time-str)
(a :href href (a :href href
:class "flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]" :class "flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]"
(div :class "font-medium text-stone-900 truncate" name) (div :class "font-medium text-stone-900 truncate" name)
(div :class "text-xs text-stone-600" date-str) (div :class "text-xs text-stone-600" date-str)
(div :class "text-xs text-stone-500" time-str))) (div :class "text-xs text-stone-500" time-str)))
(defcomp ~events-frag-entries-widget (&key cards) (defcomp ~fragments/frag-entries-widget (&key cards)
(div :class "mt-4 mb-2" (div :class "mt-4 mb-2"
(h3 :class "text-sm font-semibold text-stone-700 mb-2 px-2" "Events:") (h3 :class "text-sm font-semibold text-stone-700 mb-2 px-2" "Events:")
(div :class "overflow-x-auto scrollbar-hide" :style "scroll-behavior: smooth;" (div :class "overflow-x-auto scrollbar-hide" :style "scroll-behavior: smooth;"
@@ -23,7 +23,7 @@
;; Account page tickets (fragments/account_page_tickets.html) ;; Account page tickets (fragments/account_page_tickets.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-frag-ticket-item (&key href entry-name date-str calendar-name type-name badge) (defcomp ~fragments/frag-ticket-item (&key href entry-name date-str calendar-name type-name badge)
(div :class "py-4 first:pt-0 last:pb-0" (div :class "py-4 first:pt-0 last:pb-0"
(div :class "flex items-start justify-between gap-4" (div :class "flex items-start justify-between gap-4"
(div :class "min-w-0 flex-1" (div :class "min-w-0 flex-1"
@@ -35,13 +35,13 @@
type-name)) type-name))
(div :class "flex-shrink-0" badge)))) (div :class "flex-shrink-0" badge))))
(defcomp ~events-frag-tickets-panel (&key items) (defcomp ~fragments/frag-tickets-panel (&key items)
(div :class "w-full max-w-3xl mx-auto px-4 py-6" (div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6" (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Tickets") (h1 :class "text-xl font-semibold tracking-tight" "Tickets")
items))) items)))
(defcomp ~events-frag-tickets-list (&key items) (defcomp ~fragments/frag-tickets-list (&key items)
(div :class "divide-y divide-stone-100" items)) (div :class "divide-y divide-stone-100" items))
@@ -49,7 +49,7 @@
;; Account page bookings (fragments/account_page_bookings.html) ;; Account page bookings (fragments/account_page_bookings.html)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-frag-booking-item (&key name date-str calendar-name cost-str badge) (defcomp ~fragments/frag-booking-item (&key name date-str calendar-name cost-str badge)
(div :class "py-4 first:pt-0 last:pb-0" (div :class "py-4 first:pt-0 last:pb-0"
(div :class "flex items-start justify-between gap-4" (div :class "flex items-start justify-between gap-4"
(div :class "min-w-0 flex-1" (div :class "min-w-0 flex-1"
@@ -60,13 +60,13 @@
cost-str)) cost-str))
(div :class "flex-shrink-0" badge)))) (div :class "flex-shrink-0" badge))))
(defcomp ~events-frag-bookings-panel (&key items) (defcomp ~fragments/frag-bookings-panel (&key items)
(div :class "w-full max-w-3xl mx-auto px-4 py-6" (div :class "w-full max-w-3xl mx-auto px-4 py-6"
(div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6" (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"
(h1 :class "text-xl font-semibold tracking-tight" "Bookings") (h1 :class "text-xl font-semibold tracking-tight" "Bookings")
items))) items)))
(defcomp ~events-frag-bookings-list (&key items) (defcomp ~fragments/frag-bookings-list (&key items)
(div :class "divide-y divide-stone-100" items)) (div :class "divide-y divide-stone-100" items))
@@ -75,12 +75,12 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Container cards: list of widgets, each with entries ;; Container cards: list of widgets, each with entries
(defcomp ~events-frag-container-cards-from-data (&key widgets) (defcomp ~fragments/frag-container-cards-from-data (&key widgets)
(<> (map (lambda (w) (<> (map (lambda (w)
(if (get w "entries") (if (get w "entries")
(~events-frag-entries-widget (~fragments/frag-entries-widget
:cards (<> (map (lambda (e) :cards (<> (map (lambda (e)
(~events-frag-entry-card (~fragments/frag-entry-card
:href (get e "href") :name (get e "name") :href (get e "href") :name (get e "name")
:date-str (get e "date-str") :time-str (get e "time-str"))) :date-str (get e "date-str") :time-str (get e "time-str")))
(get w "entries")))) (get w "entries"))))
@@ -88,43 +88,43 @@
(or widgets (list))))) (or widgets (list)))))
;; Ticket item from data — composes badge + optional spans ;; Ticket item from data — composes badge + optional spans
(defcomp ~events-frag-ticket-item-from-data (&key href entry-name date-str calendar-name type-name state) (defcomp ~fragments/frag-ticket-item-from-data (&key href entry-name date-str calendar-name type-name state)
(~events-frag-ticket-item (~fragments/frag-ticket-item
:href href :entry-name entry-name :date-str date-str :href href :entry-name entry-name :date-str date-str
:calendar-name (when calendar-name (span "\u00b7 " calendar-name)) :calendar-name (when calendar-name (span "\u00b7 " calendar-name))
:type-name (when type-name (span "\u00b7 " type-name)) :type-name (when type-name (span "\u00b7 " type-name))
:badge (~status-pill :status state))) :badge (~shared:controls/status-pill :status state)))
;; Tickets panel from data — full panel with list iteration ;; Tickets panel from data — full panel with list iteration
(defcomp ~events-frag-tickets-panel-from-data (&key tickets) (defcomp ~fragments/frag-tickets-panel-from-data (&key tickets)
(~events-frag-tickets-panel (~fragments/frag-tickets-panel
:items (if (empty? (or tickets (list))) :items (if (empty? (or tickets (list)))
(~empty-state :message "No tickets yet." :cls "text-sm text-stone-500") (~shared:misc/empty-state :message "No tickets yet." :cls "text-sm text-stone-500")
(~events-frag-tickets-list (~fragments/frag-tickets-list
:items (<> (map (lambda (t) :items (<> (map (lambda (t)
(~events-frag-ticket-item-from-data (~fragments/frag-ticket-item-from-data
:href (get t "href") :entry-name (get t "entry-name") :href (get t "href") :entry-name (get t "entry-name")
:date-str (get t "date-str") :calendar-name (get t "calendar-name") :date-str (get t "date-str") :calendar-name (get t "calendar-name")
:type-name (get t "type-name") :state (get t "state"))) :type-name (get t "type-name") :state (get t "state")))
tickets)))))) tickets))))))
;; Booking item from data — composes badge + optional spans ;; Booking item from data — composes badge + optional spans
(defcomp ~events-frag-booking-item-from-data (&key name date-str end-time calendar-name cost-str state) (defcomp ~fragments/frag-booking-item-from-data (&key name date-str end-time calendar-name cost-str state)
(~events-frag-booking-item (~fragments/frag-booking-item
:name name :name name
:date-str (<> date-str (when end-time (span "\u2013 " end-time))) :date-str (<> date-str (when end-time (span "\u2013 " end-time)))
:calendar-name (when calendar-name (span "\u00b7 " calendar-name)) :calendar-name (when calendar-name (span "\u00b7 " calendar-name))
:cost-str (when cost-str (span "\u00b7 \u00a3" cost-str)) :cost-str (when cost-str (span "\u00b7 \u00a3" cost-str))
:badge (~status-pill :status state))) :badge (~shared:controls/status-pill :status state)))
;; Bookings panel from data — full panel with list iteration ;; Bookings panel from data — full panel with list iteration
(defcomp ~events-frag-bookings-panel-from-data (&key bookings) (defcomp ~fragments/frag-bookings-panel-from-data (&key bookings)
(~events-frag-bookings-panel (~fragments/frag-bookings-panel
:items (if (empty? (or bookings (list))) :items (if (empty? (or bookings (list)))
(~empty-state :message "No bookings yet." :cls "text-sm text-stone-500") (~shared:misc/empty-state :message "No bookings yet." :cls "text-sm text-stone-500")
(~events-frag-bookings-list (~fragments/frag-bookings-list
:items (<> (map (lambda (b) :items (<> (map (lambda (b)
(~events-frag-booking-item-from-data (~fragments/frag-booking-item-from-data
:href (get b "href") :name (get b "name") :href (get b "href") :name (get b "name")
:date-str (get b "date-str") :end-time (get b "end-time") :date-str (get b "date-str") :end-time (get b "end-time")
:calendar-name (get b "calendar-name") :cost-str (get b "cost-str") :calendar-name (get b "calendar-name") :cost-str (get b "cost-str")

View File

@@ -8,12 +8,12 @@
(nav-class (or (get styles "nav_button") "")) (nav-class (or (get styles "nav_button") ""))
(hx-select "#main-panel, #search-mobile, #search-count-mobile, #search-desktop, #search-count-desktop, #menu-items-nav-wrapper")) (hx-select "#main-panel, #search-mobile, #search-count-mobile, #search-desktop, #search-count-desktop, #menu-items-nav-wrapper"))
(<> (<>
(~nav-group-link (~shared:misc/nav-group-link
:href (app-url "account" "/tickets/") :href (app-url "account" "/tickets/")
:hx-select hx-select :hx-select hx-select
:nav-class nav-class :nav-class nav-class
:label "tickets") :label "tickets")
(~nav-group-link (~shared:misc/nav-group-link
:href (app-url "account" "/bookings/") :href (app-url "account" "/bookings/")
:hx-select hx-select :hx-select hx-select
:nav-class nav-class :nav-class nav-class

View File

@@ -10,13 +10,13 @@
(cond (cond
(= slug "tickets") (= slug "tickets")
(let ((tickets (service "calendar" "user-tickets" :user-id uid))) (let ((tickets (service "calendar" "user-tickets" :user-id uid)))
(~events-frag-tickets-panel (~fragments/frag-tickets-panel
:items (if (empty? tickets) :items (if (empty? tickets)
(~empty-state :message "No tickets yet." (~shared:misc/empty-state :message "No tickets yet."
:cls "text-sm text-stone-500") :cls "text-sm text-stone-500")
(~events-frag-tickets-list (~fragments/frag-tickets-list
:items (<> (map (fn (t) :items (<> (map (fn (t)
(~events-frag-ticket-item (~fragments/frag-ticket-item
:href (app-url "events" :href (app-url "events"
(str "/tickets/" (get t "code") "/")) (str "/tickets/" (get t "code") "/"))
:entry-name (get t "entry_name") :entry-name (get t "entry_name")
@@ -25,18 +25,18 @@
(span (str "\u00b7 " (get t "calendar_name")))) (span (str "\u00b7 " (get t "calendar_name"))))
:type-name (when (get t "ticket_type_name") :type-name (when (get t "ticket_type_name")
(span (str "\u00b7 " (get t "ticket_type_name")))) (span (str "\u00b7 " (get t "ticket_type_name"))))
:badge (~status-pill :status (or (get t "state") "")))) :badge (~shared:controls/status-pill :status (or (get t "state") ""))))
tickets)))))) tickets))))))
(= slug "bookings") (= slug "bookings")
(let ((bookings (service "calendar" "user-bookings" :user-id uid))) (let ((bookings (service "calendar" "user-bookings" :user-id uid)))
(~events-frag-bookings-panel (~fragments/frag-bookings-panel
:items (if (empty? bookings) :items (if (empty? bookings)
(~empty-state :message "No bookings yet." (~shared:misc/empty-state :message "No bookings yet."
:cls "text-sm text-stone-500") :cls "text-sm text-stone-500")
(~events-frag-bookings-list (~fragments/frag-bookings-list
:items (<> (map (fn (b) :items (<> (map (fn (b)
(~events-frag-booking-item (~fragments/frag-booking-item
:name (get b "name") :name (get b "name")
:date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M") :date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M")
(if (get b "end_at") (if (get b "end_at")
@@ -46,5 +46,5 @@
(span (str "\u00b7 " (get b "calendar_name")))) (span (str "\u00b7 " (get b "calendar_name"))))
:cost-str (when (get b "cost") :cost-str (when (get b "cost")
(span (str "\u00b7 \u00a3" (get b "cost")))) (span (str "\u00b7 \u00a3" (get b "cost"))))
:badge (~status-pill :status (or (get b "state") "")))) :badge (~shared:controls/status-pill :status (or (get b "state") ""))))
bookings)))))))))) bookings))))))))))

View File

@@ -19,13 +19,13 @@
(post-slug (or (nth slugs i) ""))) (post-slug (or (nth slugs i) "")))
(<> (str "<!-- card-widget:" pid " -->") (<> (str "<!-- card-widget:" pid " -->")
(when (not (empty? entries)) (when (not (empty? entries))
(~events-frag-entries-widget (~fragments/frag-entries-widget
:cards (<> (map (fn (e) :cards (<> (map (fn (e)
(let ((time-str (str (format-date (get e "start_at") "%H:%M") (let ((time-str (str (format-date (get e "start_at") "%H:%M")
(if (get e "end_at") (if (get e "end_at")
(str " \u2013 " (format-date (get e "end_at") "%H:%M")) (str " \u2013 " (format-date (get e "end_at") "%H:%M"))
"")))) ""))))
(~events-frag-entry-card (~fragments/frag-entry-card
:href (app-url "events" :href (app-url "events"
(str "/" post-slug (str "/" post-slug
"/" (get e "calendar_slug") "/" (get e "calendar_slug")

View File

@@ -53,7 +53,7 @@
(if (get entry "end_at") (if (get entry "end_at")
(str " " (format-date (get entry "end_at") "%H:%M")) (str " " (format-date (get entry "end_at") "%H:%M"))
"")))) ""))))
(~calendar-entry-nav (~shared:navigation/calendar-entry-nav
:href (app-url "events" entry-path) :href (app-url "events" entry-path)
:name (get entry "name") :name (get entry "name")
:date-str date-str :date-str date-str
@@ -61,7 +61,7 @@
;; Infinite scroll sentinel ;; Infinite scroll sentinel
(when (and has-more (not (empty? purl))) (when (and has-more (not (empty? purl)))
(~htmx-sentinel (~shared:misc/htmx-sentinel
:id (str "entries-load-sentinel-" pg) :id (str "entries-load-sentinel-" pg)
:hx-get (str purl "?page=" (+ pg 1)) :hx-get (str purl "?page=" (+ pg 1))
:hx-trigger "intersect once" :hx-trigger "intersect once"
@@ -74,7 +74,7 @@
(is-selected (if (not (empty? cur-cal)) (is-selected (if (not (empty? cur-cal))
(= (get cal "slug") cur-cal) (= (get cal "slug") cur-cal)
false))) false)))
(~calendar-link-nav (~shared:navigation/calendar-link-nav
:href href :href href
:name (get cal "name") :name (get cal "name")
:nav-class nav-class :nav-class nav-class

View File

@@ -16,7 +16,7 @@
:container-type "page" :container-type "page"
:container-id (get post "id"))) :container-id (get post "id")))
(cal-names (join ", " (map (fn (c) (get c "name")) calendars)))) (cal-names (join ", " (map (fn (c) (get c "name")) calendars))))
(~link-card (~shared:fragments/link-card
:title (get post "title") :title (get post "title")
:image (get post "feature_image") :image (get post "feature_image")
:subtitle cal-names :subtitle cal-names
@@ -28,7 +28,7 @@
:container-type "page" :container-type "page"
:container-id (get post "id"))) :container-id (get post "id")))
(cal-names (join ", " (map (fn (c) (get c "name")) calendars)))) (cal-names (join ", " (map (fn (c) (get c "name")) calendars))))
(~link-card (~shared:fragments/link-card
:title (get post "title") :title (get post "title")
:image (get post "feature_image") :image (get post "feature_image")
:subtitle cal-names :subtitle cal-names

View File

@@ -1,12 +1,12 @@
;; Events header components ;; Events header components
(defcomp ~events-calendars-label () (defcomp ~header/calendars-label ()
(<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars"))) (<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars")))
(defcomp ~events-markets-label () (defcomp ~header/markets-label ()
(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets"))) (<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets")))
(defcomp ~events-calendar-label (&key name description) (defcomp ~header/calendar-label (&key name description)
(div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0" (div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0"
(div :class "flex flex-row items-center gap-2" (div :class "flex flex-row items-center gap-2"
(i :class "fa fa-calendar") (i :class "fa fa-calendar")
@@ -15,16 +15,16 @@
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block" :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
description))) description)))
(defcomp ~events-day-label (&key date-str) (defcomp ~header/day-label (&key date-str)
(div :class "flex gap-1 items-center" (div :class "flex gap-1 items-center"
(i :class "fa fa-calendar-day") (i :class "fa fa-calendar-day")
(span date-str))) (span date-str)))
(defcomp ~events-entry-label (&key entry-id title times) (defcomp ~header/entry-label (&key entry-id title times)
(div :id (str "entry-title-" entry-id) :class "flex gap-1 items-center" (div :id (str "entry-title-" entry-id) :class "flex gap-1 items-center"
title times)) title times))
(defcomp ~events-slot-label (&key name description) (defcomp ~header/slot-label (&key name description)
(div :class "flex flex-col md:flex-row md:gap-2 items-center" (div :class "flex flex-col md:flex-row md:gap-2 items-center"
(div :class "flex flex-row items-center gap-2" (div :class "flex flex-row items-center gap-2"
(i :class "fa fa-clock") (i :class "fa fa-clock")

View File

@@ -11,20 +11,20 @@
(let ((__cal (events-calendar-ctx)) (let ((__cal (events-calendar-ctx))
(__sc (select-colours))) (__sc (select-colours)))
(when (get __cal "slug") (when (get __cal "slug")
(~menu-row-sx :id "calendar-row" :level 3 (~shared:layout/menu-row-sx :id "calendar-row" :level 3
:link-href (url-for "calendar.get" :link-href (url-for "calendar.get"
:calendar-slug (get __cal "slug")) :calendar-slug (get __cal "slug"))
:link-label-content (~events-calendar-label :link-label-content (~header/calendar-label
:name (get __cal "name") :name (get __cal "name")
:description (get __cal "description")) :description (get __cal "description"))
:nav (<> :nav (<>
(~nav-link :href (url-for "defpage_slots_listing" (~shared:layout/nav-link :href (url-for "defpage_slots_listing"
:calendar-slug (get __cal "slug")) :calendar-slug (get __cal "slug"))
:icon "fa fa-clock" :label "Slots" :icon "fa fa-clock" :label "Slots"
:select-colours __sc) :select-colours __sc)
(let ((__rights (app-rights))) (let ((__rights (app-rights)))
(when (get __rights "admin") (when (get __rights "admin")
(~nav-link :href (url-for "defpage_calendar_admin" (~shared:layout/nav-link :href (url-for "defpage_calendar_admin"
:calendar-slug (get __cal "slug")) :calendar-slug (get __cal "slug"))
:icon "fa fa-cog" :icon "fa fa-cog"
:select-colours __sc)))) :select-colours __sc))))
@@ -37,13 +37,13 @@
(let ((__cal (events-calendar-ctx)) (let ((__cal (events-calendar-ctx))
(__sc (select-colours))) (__sc (select-colours)))
(when (get __cal "slug") (when (get __cal "slug")
(~menu-row-sx :id "calendar-admin-row" :level 4 (~shared:layout/menu-row-sx :id "calendar-admin-row" :level 4
:link-label "admin" :icon "fa fa-cog" :link-label "admin" :icon "fa fa-cog"
:nav (<> :nav (<>
(~nav-link :href (url-for "defpage_slots_listing" (~shared:layout/nav-link :href (url-for "defpage_slots_listing"
:calendar-slug (get __cal "slug")) :calendar-slug (get __cal "slug"))
:label "slots" :select-colours __sc) :label "slots" :select-colours __sc)
(~nav-link :href (url-for "calendar.admin.calendar_description_edit" (~shared:layout/nav-link :href (url-for "calendar.admin.calendar_description_edit"
:calendar-slug (get __cal "slug")) :calendar-slug (get __cal "slug"))
:label "description" :select-colours __sc)) :label "description" :select-colours __sc))
:child-id "calendar-admin-header-child" :child-id "calendar-admin-header-child"
@@ -55,13 +55,13 @@
(let ((__day (events-day-ctx)) (let ((__day (events-day-ctx))
(__cal (events-calendar-ctx))) (__cal (events-calendar-ctx)))
(when (get __day "date-str") (when (get __day "date-str")
(~menu-row-sx :id "day-row" :level 4 (~shared:layout/menu-row-sx :id "day-row" :level 4
:link-href (url-for "calendar.day.show_day" :link-href (url-for "calendar.day.show_day"
:calendar-slug (get __cal "slug") :calendar-slug (get __cal "slug")
:year (get __day "year") :year (get __day "year")
:month (get __day "month") :month (get __day "month")
:day (get __day "day")) :day (get __day "day"))
:link-label-content (~events-day-label :link-label-content (~header/day-label
:date-str (get __day "date-str")) :date-str (get __day "date-str"))
:nav (get __day "nav") :nav (get __day "nav")
:child-id "day-header-child" :child-id "day-header-child"
@@ -73,7 +73,7 @@
(let ((__day (events-day-ctx)) (let ((__day (events-day-ctx))
(__cal (events-calendar-ctx))) (__cal (events-calendar-ctx)))
(when (get __day "date-str") (when (get __day "date-str")
(~menu-row-sx :id "day-admin-row" :level 5 (~shared:layout/menu-row-sx :id "day-admin-row" :level 5
:link-href (url-for "defpage_day_admin" :link-href (url-for "defpage_day_admin"
:calendar-slug (get __cal "slug") :calendar-slug (get __cal "slug")
:year (get __day "year") :year (get __day "year")
@@ -88,12 +88,12 @@
(quasiquote (quasiquote
(let ((__ectx (events-entry-ctx))) (let ((__ectx (events-entry-ctx)))
(when (get __ectx "id") (when (get __ectx "id")
(~menu-row-sx :id "entry-row" :level 5 (~shared:layout/menu-row-sx :id "entry-row" :level 5
:link-href (get __ectx "link-href") :link-href (get __ectx "link-href")
:link-label-content (~events-entry-label :link-label-content (~header/entry-label
:entry-id (get __ectx "id") :entry-id (get __ectx "id")
:title (~events-entry-title :name (get __ectx "name")) :title (~admin/entry-title :name (get __ectx "name"))
:times (~events-entry-times :time-str (get __ectx "time-str"))) :times (~admin/entry-times :time-str (get __ectx "time-str")))
:nav (get __ectx "nav") :nav (get __ectx "nav")
:child-id "entry-header-child" :child-id "entry-header-child"
:oob (unquote oob)))))) :oob (unquote oob))))))
@@ -103,11 +103,11 @@
(quasiquote (quasiquote
(let ((__ectx (events-entry-ctx))) (let ((__ectx (events-entry-ctx)))
(when (get __ectx "id") (when (get __ectx "id")
(~menu-row-sx :id "entry-admin-row" :level 6 (~shared:layout/menu-row-sx :id "entry-admin-row" :level 6
:link-href (get __ectx "admin-href") :link-href (get __ectx "admin-href")
:link-label "admin" :icon "fa fa-cog" :link-label "admin" :icon "fa fa-cog"
:nav (when (get __ectx "is-admin") :nav (when (get __ectx "is-admin")
(~nav-link :href (get __ectx "ticket-types-href") (~shared:layout/nav-link :href (get __ectx "ticket-types-href")
:label "ticket_types" :label "ticket_types"
:select-colours (get __ectx "select-colours"))) :select-colours (get __ectx "select-colours")))
:child-id "entry-admin-header-child" :child-id "entry-admin-header-child"
@@ -118,8 +118,8 @@
(quasiquote (quasiquote
(let ((__slot (events-slot-ctx))) (let ((__slot (events-slot-ctx)))
(when (get __slot "name") (when (get __slot "name")
(~menu-row-sx :id "slot-row" :level 5 (~shared:layout/menu-row-sx :id "slot-row" :level 5
:link-label-content (~events-slot-label :link-label-content (~header/slot-label
:name (get __slot "name") :name (get __slot "name")
:description (get __slot "description")) :description (get __slot "description"))
:child-id "slot-header-child" :child-id "slot-header-child"
@@ -131,12 +131,12 @@
(let ((__ectx (events-entry-ctx)) (let ((__ectx (events-entry-ctx))
(__cal (events-calendar-ctx))) (__cal (events-calendar-ctx)))
(when (get __ectx "id") (when (get __ectx "id")
(~menu-row-sx :id "ticket_types-row" :level 7 (~shared:layout/menu-row-sx :id "ticket_types-row" :level 7
:link-href (get __ectx "ticket-types-href") :link-href (get __ectx "ticket-types-href")
:link-label-content (<> :link-label-content (<>
(i :class "fa fa-ticket") (i :class "fa fa-ticket")
(div :class "shrink-0" "ticket types")) (div :class "shrink-0" "ticket types"))
:nav (~events-admin-placeholder-nav) :nav (~forms/admin-placeholder-nav)
:child-id "ticket_type-header-child" :child-id "ticket_type-header-child"
:oob (unquote oob)))))) :oob (unquote oob))))))
@@ -145,22 +145,22 @@
(quasiquote (quasiquote
(let ((__tt (events-ticket-type-ctx))) (let ((__tt (events-ticket-type-ctx)))
(when (get __tt "id") (when (get __tt "id")
(~menu-row-sx :id "ticket_type-row" :level 8 (~shared:layout/menu-row-sx :id "ticket_type-row" :level 8
:link-href (get __tt "link-href") :link-href (get __tt "link-href")
:link-label-content (div :class "flex flex-col md:flex-row md:gap-2 items-center" :link-label-content (div :class "flex flex-col md:flex-row md:gap-2 items-center"
(div :class "flex flex-row items-center gap-2" (div :class "flex flex-row items-center gap-2"
(i :class "fa fa-ticket") (i :class "fa fa-ticket")
(div :class "shrink-0" (get __tt "name")))) (div :class "shrink-0" (get __tt "name"))))
:nav (~events-admin-placeholder-nav) :nav (~forms/admin-placeholder-nav)
:child-id "ticket_type-header-child-inner" :child-id "ticket_type-header-child-inner"
:oob (unquote oob)))))) :oob (unquote oob))))))
(defmacro ~events-markets-header-auto (oob) (defmacro ~events-markets-header-auto (oob)
"Markets section header row." "Markets section header row."
(quasiquote (quasiquote
(~menu-row-sx :id "markets-row" :level 3 (~shared:layout/menu-row-sx :id "markets-row" :level 3
:link-href (url-for "defpage_events_markets") :link-href (url-for "defpage_events_markets")
:link-label-content (~events-markets-label) :link-label-content (~header/markets-label)
:child-id "markets-header-child" :child-id "markets-header-child"
:oob (unquote oob)))) :oob (unquote oob))))
@@ -168,218 +168,218 @@
;; OOB clear helpers — clear deeper header rows not present at this level ;; OOB clear helpers — clear deeper header rows not present at this level
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-clear-oob-cal-admin () (defcomp ~layouts/clear-oob-cal-admin ()
"Clear OOB divs for cal-admin level (keeps down to calendar-admin)." "Clear OOB divs for cal-admin level (keeps down to calendar-admin)."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-row")
(~clear-oob-div :id "entry-header-child") (~shared:layout/clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-row")
(~clear-oob-div :id "day-admin-header-child") (~shared:layout/clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-row")
(~clear-oob-div :id "day-header-child") (~shared:layout/clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child"))) (~shared:layout/clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-oob-slot () (defcomp ~layouts/clear-oob-slot ()
"Clear OOB divs for slot level." "Clear OOB divs for slot level."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-row")
(~clear-oob-div :id "entry-header-child") (~shared:layout/clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-row")
(~clear-oob-div :id "day-admin-header-child") (~shared:layout/clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-row")
(~clear-oob-div :id "day-header-child") (~shared:layout/clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child"))) (~shared:layout/clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-oob-day-admin () (defcomp ~layouts/clear-oob-day-admin ()
"Clear OOB divs for day-admin level." "Clear OOB divs for day-admin level."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-row")
(~clear-oob-div :id "entry-header-child") (~shared:layout/clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child"))) (~shared:layout/clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-oob-entry () (defcomp ~layouts/clear-oob-entry ()
"Clear OOB divs for entry level (public, no admin rows)." "Clear OOB divs for entry level (public, no admin rows)."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-row")
(~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-row")
(~clear-oob-div :id "day-admin-header-child") (~shared:layout/clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-row")
(~clear-oob-div :id "calendar-admin-header-child") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child") (~shared:layout/clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row") (~shared:layout/clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child"))) (~shared:layout/clear-oob-div :id "post-admin-header-child")))
(defcomp ~events-clear-oob-entry-admin () (defcomp ~layouts/clear-oob-entry-admin ()
"Clear OOB divs for entry-admin level." "Clear OOB divs for entry-admin level."
(<> (<>
(~clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-row")
(~clear-oob-div :id "calendars-header-child"))) (~shared:layout/clear-oob-div :id "calendars-header-child")))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; OOB clear helpers for renders.py — clear all deeper IDs except kept ones ;; OOB clear helpers for renders.py — clear all deeper IDs except kept ones
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-clear-deeper-post () (defcomp ~layouts/clear-deeper-post ()
"Clear all events IDs deeper than post level." "Clear all events IDs deeper than post level."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child") (~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child") (~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child") (~shared:layout/clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child") (~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendar-row") (~clear-oob-div :id "calendar-header-child") (~shared:layout/clear-oob-div :id "calendar-row") (~shared:layout/clear-oob-div :id "calendar-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child") (~shared:layout/clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child"))) (~shared:layout/clear-oob-div :id "post-admin-row") (~shared:layout/clear-oob-div :id "post-admin-header-child")))
(defcomp ~events-clear-deeper-post-admin () (defcomp ~layouts/clear-deeper-post-admin ()
"Clear all events IDs deeper than post-admin level." "Clear all events IDs deeper than post-admin level."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child") (~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child") (~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child") (~shared:layout/clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child") (~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendar-row") (~clear-oob-div :id "calendar-header-child") (~shared:layout/clear-oob-div :id "calendar-row") (~shared:layout/clear-oob-div :id "calendar-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child"))) (~shared:layout/clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-header-child")))
(defcomp ~events-clear-deeper-calendar () (defcomp ~layouts/clear-deeper-calendar ()
"Clear all events IDs deeper than calendar level." "Clear all events IDs deeper than calendar level."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child") (~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child") (~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child") (~shared:layout/clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child") (~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child") (~shared:layout/clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child"))) (~shared:layout/clear-oob-div :id "post-admin-row") (~shared:layout/clear-oob-div :id "post-admin-header-child")))
(defcomp ~events-clear-deeper-day () (defcomp ~layouts/clear-deeper-day ()
"Clear all events IDs deeper than day level." "Clear all events IDs deeper than day level."
(<> (<>
(~clear-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child") (~shared:layout/clear-oob-div :id "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child") (~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child") (~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child") (~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
(~clear-oob-div :id "calendars-row") (~clear-oob-div :id "calendars-header-child") (~shared:layout/clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-header-child")
(~clear-oob-div :id "post-admin-row") (~clear-oob-div :id "post-admin-header-child"))) (~shared:layout/clear-oob-div :id "post-admin-row") (~shared:layout/clear-oob-div :id "post-admin-header-child")))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Calendar admin layout: root + post + child(post-admin + cal + cal-admin) ;; Calendar admin layout: root + post + child(post-admin + cal + cal-admin)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-cal-admin-layout-full () (defcomp ~layouts/cal-admin-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars") (~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
(~events-calendar-admin-header-auto nil))))) (~events-calendar-admin-header-auto nil)))))
(defcomp ~events-cal-admin-layout-oob () (defcomp ~layouts/cal-admin-layout-oob ()
(<> (~post-admin-header-auto true "calendars") (<> (~post-admin-header-auto true "calendars")
(~events-calendar-header-auto true) (~events-calendar-header-auto true)
(~oob-header-sx :parent-id "calendar-header-child" (~shared:layout/oob-header-sx :parent-id "calendar-header-child"
:row (~events-calendar-admin-header-auto nil)) :row (~events-calendar-admin-header-auto nil))
(~events-clear-oob-cal-admin) (~layouts/clear-oob-cal-admin)
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Slots layout: same full as cal-admin ;; Slots layout: same full as cal-admin
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-slots-layout-full () (defcomp ~layouts/slots-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars") (~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
(~events-calendar-admin-header-auto nil))))) (~events-calendar-admin-header-auto nil)))))
(defcomp ~events-slots-layout-oob () (defcomp ~layouts/slots-layout-oob ()
(<> (~post-admin-header-auto true "calendars") (<> (~post-admin-header-auto true "calendars")
(~events-calendar-admin-header-auto true) (~events-calendar-admin-header-auto true)
(~events-clear-oob-cal-admin) (~layouts/clear-oob-cal-admin)
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Slot detail layout: root + post + child(admin + cal + cal-admin + slot) ;; Slot detail layout: root + post + child(admin + cal + cal-admin + slot)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-slot-layout-full () (defcomp ~layouts/slot-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars") (~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
(~events-calendar-admin-header-auto nil) (~events-calendar-admin-header-auto nil)
(~events-slot-header-auto nil))))) (~events-slot-header-auto nil)))))
(defcomp ~events-slot-layout-oob () (defcomp ~layouts/slot-layout-oob ()
(<> (~post-admin-header-auto true "calendars") (<> (~post-admin-header-auto true "calendars")
(~events-calendar-admin-header-auto true) (~events-calendar-admin-header-auto true)
(~oob-header-sx :parent-id "calendar-admin-header-child" (~shared:layout/oob-header-sx :parent-id "calendar-admin-header-child"
:row (~events-slot-header-auto nil)) :row (~events-slot-header-auto nil))
(~events-clear-oob-slot) (~layouts/clear-oob-slot)
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Day admin layout: root + post + child(admin + cal + day + day-admin) ;; Day admin layout: root + post + child(admin + cal + day + day-admin)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-day-admin-layout-full () (defcomp ~layouts/day-admin-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars") (~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
(~events-day-header-auto nil) (~events-day-header-auto nil)
(~events-day-admin-header-auto nil))))) (~events-day-admin-header-auto nil)))))
(defcomp ~events-day-admin-layout-oob () (defcomp ~layouts/day-admin-layout-oob ()
(<> (~post-admin-header-auto true "calendars") (<> (~post-admin-header-auto true "calendars")
(~events-calendar-header-auto true) (~events-calendar-header-auto true)
(~oob-header-sx :parent-id "day-header-child" (~shared:layout/oob-header-sx :parent-id "day-header-child"
:row (~events-day-admin-header-auto nil)) :row (~events-day-admin-header-auto nil))
(~events-clear-oob-day-admin) (~layouts/clear-oob-day-admin)
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Entry layout: root + child(post + cal + day + entry) — public, no admin ;; Entry layout: root + child(post + cal + day + entry) — public, no admin
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-entry-layout-full () (defcomp ~layouts/entry-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
(~events-day-header-auto nil) (~events-day-header-auto nil)
(~events-entry-header-auto nil))))) (~events-entry-header-auto nil)))))
(defcomp ~events-entry-layout-oob () (defcomp ~layouts/entry-layout-oob ()
(<> (~events-day-header-auto true) (<> (~events-day-header-auto true)
(~oob-header-sx :parent-id "day-header-child" (~shared:layout/oob-header-sx :parent-id "day-header-child"
:row (~events-entry-header-auto nil)) :row (~events-entry-header-auto nil))
(~events-clear-oob-entry) (~layouts/clear-oob-entry)
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Entry admin layout: root + post + child(admin + cal + day + entry + entry-admin) ;; Entry admin layout: root + post + child(admin + cal + day + entry + entry-admin)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-entry-admin-layout-full () (defcomp ~layouts/entry-admin-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~post-admin-header-auto nil "calendars") (~post-admin-header-auto nil "calendars")
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
@@ -387,21 +387,21 @@
(~events-entry-header-auto nil) (~events-entry-header-auto nil)
(~events-entry-admin-header-auto nil))))) (~events-entry-admin-header-auto nil)))))
(defcomp ~events-entry-admin-layout-oob () (defcomp ~layouts/entry-admin-layout-oob ()
(<> (~post-admin-header-auto true "calendars") (<> (~post-admin-header-auto true "calendars")
(~events-entry-header-auto true) (~events-entry-header-auto true)
(~oob-header-sx :parent-id "entry-header-child" (~shared:layout/oob-header-sx :parent-id "entry-header-child"
:row (~events-entry-admin-header-auto nil)) :row (~events-entry-admin-header-auto nil))
(~events-clear-oob-entry-admin) (~layouts/clear-oob-entry-admin)
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Ticket types layout: root + child(post + cal + day + entry + entry-admin + ticket-types) ;; Ticket types layout: root + child(post + cal + day + entry + entry-admin + ticket-types)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-ticket-types-layout-full () (defcomp ~layouts/ticket-types-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
(~events-day-header-auto nil) (~events-day-header-auto nil)
@@ -409,9 +409,9 @@
(~events-entry-admin-header-auto nil) (~events-entry-admin-header-auto nil)
(~events-ticket-types-header-auto nil))))) (~events-ticket-types-header-auto nil)))))
(defcomp ~events-ticket-types-layout-oob () (defcomp ~layouts/ticket-types-layout-oob ()
(<> (~events-entry-admin-header-auto true) (<> (~events-entry-admin-header-auto true)
(~oob-header-sx :parent-id "entry-admin-header-child" (~shared:layout/oob-header-sx :parent-id "entry-admin-header-child"
:row (~events-ticket-types-header-auto nil)) :row (~events-ticket-types-header-auto nil))
(~root-header-auto true))) (~root-header-auto true)))
@@ -419,9 +419,9 @@
;; Ticket type layout: all headers down to ticket-type ;; Ticket type layout: all headers down to ticket-type
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-ticket-type-layout-full () (defcomp ~layouts/ticket-type-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~events-calendar-header-auto nil) (~events-calendar-header-auto nil)
(~events-day-header-auto nil) (~events-day-header-auto nil)
@@ -430,9 +430,9 @@
(~events-ticket-types-header-auto nil) (~events-ticket-types-header-auto nil)
(~events-ticket-type-header-auto nil))))) (~events-ticket-type-header-auto nil)))))
(defcomp ~events-ticket-type-layout-oob () (defcomp ~layouts/ticket-type-layout-oob ()
(<> (~events-ticket-types-header-auto true) (<> (~events-ticket-types-header-auto true)
(~oob-header-sx :parent-id "ticket_types-header-child" (~shared:layout/oob-header-sx :parent-id "ticket_types-header-child"
:row (~events-ticket-type-header-auto nil)) :row (~events-ticket-type-header-auto nil))
(~root-header-auto true))) (~root-header-auto true)))
@@ -440,14 +440,14 @@
;; Markets layout: root + child(post + markets) ;; Markets layout: root + child(post + markets)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~events-markets-layout-full () (defcomp ~layouts/markets-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~events-markets-header-auto nil))))) (~events-markets-header-auto nil)))))
(defcomp ~events-markets-layout-oob () (defcomp ~layouts/markets-layout-oob ()
(<> (~post-header-auto true) (<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child" (~shared:layout/oob-header-sx :parent-id "post-header-child"
:row (~events-markets-header-auto nil)) :row (~events-markets-header-auto nil))
(~root-header-auto true))) (~root-header-auto true)))

View File

@@ -1,15 +1,15 @@
;; Events page-level components (slots, ticket types, buy form, cart, posts nav) ;; Events page-level components (slots, ticket types, buy form, cart, posts nav)
(defcomp ~events-slot-days-pills (&key days-inner) (defcomp ~page/slot-days-pills (&key days-inner)
(div :class "flex flex-wrap gap-1" days-inner)) (div :class "flex flex-wrap gap-1" days-inner))
(defcomp ~events-slot-day-pill (&key day) (defcomp ~page/slot-day-pill (&key day)
(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" day)) (span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" day))
(defcomp ~events-slot-no-days () (defcomp ~page/slot-no-days ()
(span :class "text-xs text-slate-400" "No days")) (span :class "text-xs text-slate-400" "No days"))
(defcomp ~events-slot-panel (&key slot-id list-container days flexible time-str cost-str pre-action edit-url) (defcomp ~page/slot-panel (&key slot-id list-container days flexible time-str cost-str pre-action edit-url)
(section :id (str "slot-" slot-id) :class list-container (section :id (str "slot-" slot-id) :class list-container
(div :class "flex flex-col" (div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Days") (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Days")
@@ -27,15 +27,15 @@
(button :type "button" :class pre-action :sx-get edit-url (button :type "button" :class pre-action :sx-get edit-url
:sx-target (str "#slot-" slot-id) :sx-swap "outerHTML" "Edit"))) :sx-target (str "#slot-" slot-id) :sx-swap "outerHTML" "Edit")))
(defcomp ~events-slot-description-oob (&key description) (defcomp ~page/slot-description-oob (&key description)
(div :id "slot-description-title" :sx-swap-oob "outerHTML" (div :id "slot-description-title" :sx-swap-oob "outerHTML"
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block" :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
description)) description))
(defcomp ~events-slots-empty-row () (defcomp ~page/slots-empty-row ()
(tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet."))) (tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet.")))
(defcomp ~events-slots-row (&key tr-cls slot-href pill-cls hx-select slot-name description (defcomp ~page/slots-row (&key tr-cls slot-href pill-cls hx-select slot-name description
flexible days time-str cost-str action-btn del-url csrf-hdr) flexible days time-str cost-str action-btn del-url csrf-hdr)
(tr :class tr-cls (tr :class tr-cls
(td :class "p-2 align-top w-1/6" (td :class "p-2 align-top w-1/6"
@@ -57,7 +57,7 @@
:sx-swap "outerHTML" :sx-headers csrf-hdr :sx-trigger "confirmed" :sx-swap "outerHTML" :sx-headers csrf-hdr :sx-trigger "confirmed"
(i :class "fa-solid fa-trash"))))) (i :class "fa-solid fa-trash")))))
(defcomp ~events-slots-table (&key list-container rows pre-action add-url) (defcomp ~page/slots-table (&key list-container rows pre-action add-url)
(section :id "slots-table" :class list-container (section :id "slots-table" :class list-container
(table :class "w-full text-sm border table-fixed" (table :class "w-full text-sm border table-fixed"
(thead :class "bg-stone-100" (thead :class "bg-stone-100"
@@ -78,61 +78,61 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Days pills from data — replaces Python loop ;; Days pills from data — replaces Python loop
(defcomp ~events-days-pills-from-data (&key days) (defcomp ~page/days-pills-from-data (&key days)
(if (empty? (or days (list))) (if (empty? (or days (list)))
(~events-slot-no-days) (~page/slot-no-days)
(~events-slot-days-pills (~page/slot-days-pills
:days-inner (<> (map (lambda (d) (~events-slot-day-pill :day d)) days))))) :days-inner (<> (map (lambda (d) (~page/slot-day-pill :day d)) days)))))
;; Slot panel from data ;; Slot panel from data
(defcomp ~events-slot-panel-from-data (&key slot-id list-container days (defcomp ~page/slot-panel-from-data (&key slot-id list-container days
flexible time-str cost-str flexible time-str cost-str
pre-action edit-url description oob) pre-action edit-url description oob)
(<> (<>
(~events-slot-panel (~page/slot-panel
:slot-id slot-id :list-container list-container :slot-id slot-id :list-container list-container
:days (~events-days-pills-from-data :days days) :days (~page/days-pills-from-data :days days)
:flexible flexible :time-str time-str :cost-str cost-str :flexible flexible :time-str time-str :cost-str cost-str
:pre-action pre-action :edit-url edit-url) :pre-action pre-action :edit-url edit-url)
(when oob (when oob
(~events-slot-description-oob :description (or description ""))))) (~page/slot-description-oob :description (or description "")))))
;; Slots table from data ;; Slots table from data
(defcomp ~events-slots-table-from-data (&key list-container slots pre-action add-url (defcomp ~page/slots-table-from-data (&key list-container slots pre-action add-url
tr-cls pill-cls action-btn hx-select csrf-hdr) tr-cls pill-cls action-btn hx-select csrf-hdr)
(~events-slots-table (~page/slots-table
:list-container list-container :list-container list-container
:rows (if (empty? (or slots (list))) :rows (if (empty? (or slots (list)))
(~events-slots-empty-row) (~page/slots-empty-row)
(<> (map (lambda (s) (<> (map (lambda (s)
(~events-slots-row (~page/slots-row
:tr-cls tr-cls :slot-href (get s "slot-href") :tr-cls tr-cls :slot-href (get s "slot-href")
:pill-cls pill-cls :hx-select hx-select :pill-cls pill-cls :hx-select hx-select
:slot-name (get s "slot-name") :description (get s "description") :slot-name (get s "slot-name") :description (get s "description")
:flexible (get s "flexible") :flexible (get s "flexible")
:days (~events-days-pills-from-data :days (get s "days")) :days (~page/days-pills-from-data :days (get s "days"))
:time-str (get s "time-str") :time-str (get s "time-str")
:cost-str (get s "cost-str") :action-btn action-btn :cost-str (get s "cost-str") :action-btn action-btn
:del-url (get s "del-url") :csrf-hdr csrf-hdr)) :del-url (get s "del-url") :csrf-hdr csrf-hdr))
(or slots (list))))) (or slots (list)))))
:pre-action pre-action :add-url add-url)) :pre-action pre-action :add-url add-url))
(defcomp ~events-ticket-type-col (&key label value) (defcomp ~page/ticket-type-col (&key label value)
(div :class "flex flex-col" (div :class "flex flex-col"
(div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label) (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
(div :class "mt-1" value))) (div :class "mt-1" value)))
(defcomp ~events-ticket-type-panel (&key ticket-id list-container c1 c2 c3 pre-action edit-url) (defcomp ~page/ticket-type-panel (&key ticket-id list-container c1 c2 c3 pre-action edit-url)
(section :id (str "ticket-" ticket-id) :class list-container (section :id (str "ticket-" ticket-id) :class list-container
(div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm" (div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm"
c1 c2 c3) c1 c2 c3)
(button :type "button" :class pre-action :sx-get edit-url (button :type "button" :class pre-action :sx-get edit-url
:sx-target (str "#ticket-" ticket-id) :sx-swap "outerHTML" "Edit"))) :sx-target (str "#ticket-" ticket-id) :sx-swap "outerHTML" "Edit")))
(defcomp ~events-ticket-types-empty-row () (defcomp ~page/ticket-types-empty-row ()
(tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet."))) (tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet.")))
(defcomp ~events-ticket-types-row (&key tr-cls tt-href pill-cls hx-select tt-name cost-str count (defcomp ~page/ticket-types-row (&key tr-cls tt-href pill-cls hx-select tt-name cost-str count
action-btn del-url csrf-hdr) action-btn del-url csrf-hdr)
(tr :class tr-cls (tr :class tr-cls
(td :class "p-2 align-top w-1/3" (td :class "p-2 align-top w-1/3"
@@ -151,7 +151,7 @@
:sx-swap "outerHTML" :sx-headers csrf-hdr :sx-trigger "confirmed" :sx-swap "outerHTML" :sx-headers csrf-hdr :sx-trigger "confirmed"
(i :class "fa-solid fa-trash"))))) (i :class "fa-solid fa-trash")))))
(defcomp ~events-ticket-types-table (&key list-container rows action-btn add-url) (defcomp ~page/ticket-types-table (&key list-container rows action-btn add-url)
(section :id "tickets-table" :class list-container (section :id "tickets-table" :class list-container
(table :class "w-full text-sm border table-fixed" (table :class "w-full text-sm border table-fixed"
(thead :class "bg-stone-100" (thead :class "bg-stone-100"
@@ -164,7 +164,7 @@
(button :class action-btn :sx-get add-url :sx-target "#ticket-add-container" :sx-swap "innerHTML" (button :class action-btn :sx-get add-url :sx-target "#ticket-add-container" :sx-swap "innerHTML"
(i :class "fa fa-plus") " Add ticket type")))) (i :class "fa fa-plus") " Add ticket type"))))
(defcomp ~events-ticket-config-display (&key price-str count-str show-js) (defcomp ~page/ticket-config-display (&key price-str count-str show-js)
(div :class "space-y-2" (div :class "space-y-2"
(div :class "flex items-center gap-2" (div :class "flex items-center gap-2"
(span :class "text-sm font-medium text-stone-700" "Price:") (span :class "text-sm font-medium text-stone-700" "Price:")
@@ -175,13 +175,13 @@
(button :type "button" :class "text-xs text-blue-600 hover:text-blue-800 underline" (button :type "button" :class "text-xs text-blue-600 hover:text-blue-800 underline"
:onclick show-js "Edit ticket config"))) :onclick show-js "Edit ticket config")))
(defcomp ~events-ticket-config-none (&key show-js) (defcomp ~page/ticket-config-none (&key show-js)
(div :class "space-y-2" (div :class "space-y-2"
(span :class "text-sm text-stone-400" "No tickets configured") (span :class "text-sm text-stone-400" "No tickets configured")
(button :type "button" :class "block text-xs text-blue-600 hover:text-blue-800 underline" (button :type "button" :class "block text-xs text-blue-600 hover:text-blue-800 underline"
:onclick show-js "Configure tickets"))) :onclick show-js "Configure tickets")))
(defcomp ~events-ticket-config-form (&key entry-id hidden-cls update-url csrf price-val count-val hide-js) (defcomp ~page/ticket-config-form (&key entry-id hidden-cls update-url csrf price-val count-val hide-js)
(form :id (str "ticket-form-" entry-id) :class (str hidden-cls " space-y-3 mt-2 p-3 border rounded bg-stone-50") (form :id (str "ticket-form-" entry-id) :class (str hidden-cls " space-y-3 mt-2 p-3 border rounded bg-stone-50")
:sx-post update-url :sx-target (str "#entry-tickets-" entry-id) :sx-swap "innerHTML" :sx-post update-url :sx-target (str "#entry-tickets-" entry-id) :sx-swap "innerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
@@ -203,12 +203,12 @@
:onclick hide-js "Cancel")))) :onclick hide-js "Cancel"))))
;; Data-driven buy form — Python passes pre-resolved data, .sx does layout + iteration ;; Data-driven buy form — Python passes pre-resolved data, .sx does layout + iteration
(defcomp ~events-buy-form (&key entry-id info-sold info-remaining info-basket (defcomp ~page/buy-form (&key entry-id info-sold info-remaining info-basket
ticket-types user-ticket-counts-by-type ticket-types user-ticket-counts-by-type
user-ticket-count price-str adjust-url csrf state user-ticket-count price-str adjust-url csrf state
my-tickets-href) my-tickets-href)
(if (!= state "confirmed") (if (!= state "confirmed")
(~events-buy-not-confirmed :entry-id (str entry-id)) (~page/buy-not-confirmed :entry-id (str entry-id))
(let ((eid-s (str entry-id)) (let ((eid-s (str entry-id))
(target (str "#ticket-buy-" entry-id))) (target (str "#ticket-buy-" entry-id)))
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-white p-4" (div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-white p-4"
@@ -234,19 +234,19 @@
(div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100" (div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100"
(div (div :class "font-medium text-sm" (get tt "name")) (div (div :class "font-medium text-sm" (get tt "name"))
(div :class "text-xs text-stone-500" (get tt "cost_str"))) (div :class "text-xs text-stone-500" (get tt "cost_str")))
(~events-adjust-inline :csrf csrf :adjust-url adjust-url :target target (~page/adjust-inline :csrf csrf :adjust-url adjust-url :target target
:entry-id eid-s :count tt-count :ticket-type-id tt-id :entry-id eid-s :count tt-count :ticket-type-id tt-id
:my-tickets-href my-tickets-href)))) :my-tickets-href my-tickets-href))))
ticket-types)) ticket-types))
(<> (div :class "flex items-center justify-between mb-4" (<> (div :class "flex items-center justify-between mb-4"
(div (span :class "font-medium text-green-600" price-str) (div (span :class "font-medium text-green-600" price-str)
(span :class "text-sm text-stone-500 ml-2" "per ticket"))) (span :class "text-sm text-stone-500 ml-2" "per ticket")))
(~events-adjust-inline :csrf csrf :adjust-url adjust-url :target target (~page/adjust-inline :csrf csrf :adjust-url adjust-url :target target
:entry-id eid-s :count (if user-ticket-count user-ticket-count 0) :entry-id eid-s :count (if user-ticket-count user-ticket-count 0)
:ticket-type-id nil :my-tickets-href my-tickets-href))))))) :ticket-type-id nil :my-tickets-href my-tickets-href)))))))
;; Inline +/- controls (used by both default and per-type) ;; Inline +/- controls (used by both default and per-type)
(defcomp ~events-adjust-inline (&key csrf adjust-url target entry-id count ticket-type-id my-tickets-href) (defcomp ~page/adjust-inline (&key csrf adjust-url target entry-id count ticket-type-id my-tickets-href)
(if (= count 0) (if (= count 0)
(form :sx-post adjust-url :sx-target target :sx-swap "outerHTML" :class "flex items-center" (form :sx-post adjust-url :sx-target target :sx-swap "outerHTML" :class "flex items-center"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
@@ -279,13 +279,13 @@
: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" :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"
"+"))))) "+")))))
(defcomp ~events-buy-not-confirmed (&key entry-id) (defcomp ~page/buy-not-confirmed (&key entry-id)
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500" (div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500"
(i :class "fa fa-ticket mr-1" :aria-hidden "true") (i :class "fa fa-ticket mr-1" :aria-hidden "true")
"Tickets available once this event is confirmed.")) "Tickets available once this event is confirmed."))
(defcomp ~events-buy-result (&key entry-id tickets remaining my-tickets-href) (defcomp ~page/buy-result (&key entry-id tickets remaining my-tickets-href)
(let ((count (len tickets)) (let ((count (len tickets))
(suffix (if (= count 1) "" "s"))) (suffix (if (= count 1) "" "s")))
(div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4" (div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4"
@@ -308,21 +308,21 @@
"View all my tickets"))))) "View all my tickets")))))
;; Single response wrappers for POST routes (include OOB cart icon) ;; Single response wrappers for POST routes (include OOB cart icon)
(defcomp ~events-buy-response (&key entry-id tickets remaining my-tickets-href (defcomp ~page/buy-response (&key entry-id tickets remaining my-tickets-href
cart-count blog-href cart-href logo) cart-count blog-href cart-href logo)
(<> (<>
(~events-cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo) (~page/cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
(~events-buy-result :entry-id entry-id :tickets tickets :remaining remaining (~page/buy-result :entry-id entry-id :tickets tickets :remaining remaining
:my-tickets-href my-tickets-href))) :my-tickets-href my-tickets-href)))
(defcomp ~events-adjust-response (&key cart-count blog-href cart-href logo (defcomp ~page/adjust-response (&key cart-count blog-href cart-href logo
entry-id info-sold info-remaining info-basket entry-id info-sold info-remaining info-basket
ticket-types user-ticket-counts-by-type ticket-types user-ticket-counts-by-type
user-ticket-count price-str adjust-url csrf state user-ticket-count price-str adjust-url csrf state
my-tickets-href) my-tickets-href)
(<> (<>
(~events-cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo) (~page/cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
(~events-buy-form :entry-id entry-id :info-sold info-sold :info-remaining info-remaining (~page/buy-form :entry-id entry-id :info-sold info-sold :info-remaining info-remaining
:info-basket info-basket :ticket-types ticket-types :info-basket info-basket :ticket-types ticket-types
:user-ticket-counts-by-type user-ticket-counts-by-type :user-ticket-counts-by-type user-ticket-counts-by-type
:user-ticket-count user-ticket-count :price-str price-str :user-ticket-count user-ticket-count :price-str price-str
@@ -330,18 +330,18 @@
:my-tickets-href my-tickets-href))) :my-tickets-href my-tickets-href)))
;; Unified OOB cart icon — picks logo or badge based on count ;; Unified OOB cart icon — picks logo or badge based on count
(defcomp ~events-cart-icon (&key cart-count blog-href cart-href logo) (defcomp ~page/cart-icon (&key cart-count blog-href cart-href logo)
(if (= cart-count 0) (if (= cart-count 0)
(~events-cart-icon-logo :blog-href blog-href :logo logo) (~page/cart-icon-logo :blog-href blog-href :logo logo)
(~events-cart-icon-badge :cart-href cart-href :count (str cart-count)))) (~page/cart-icon-badge :cart-href cart-href :count (str cart-count))))
(defcomp ~events-cart-icon-logo (&key blog-href logo) (defcomp ~page/cart-icon-logo (&key blog-href logo)
(div :id "cart-mini" :sx-swap-oob "true" (div :id "cart-mini" :sx-swap-oob "true"
(div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0" (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"
(a :href blog-href :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1" (a :href blog-href :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
(img :src logo :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))))) (img :src logo :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0")))))
(defcomp ~events-cart-icon-badge (&key cart-href count) (defcomp ~page/cart-icon-badge (&key cart-href count)
(div :id "cart-mini" :sx-swap-oob "true" (div :id "cart-mini" :sx-swap-oob "true"
(a :href cart-href :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700" (a :href cart-href :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
(i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true") (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")
@@ -349,37 +349,37 @@
count)))) count))))
;; Inline ticket widget (for all-events/page-summary cards) ;; Inline ticket widget (for all-events/page-summary cards)
(defcomp ~events-tw-form (&key ticket-url target csrf entry-id count-val btn) (defcomp ~page/tw-form (&key ticket-url target csrf entry-id count-val btn)
(form :action ticket-url :method "post" :sx-post ticket-url :sx-target target :sx-swap "outerHTML" (form :action ticket-url :method "post" :sx-post ticket-url :sx-target target :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "entry_id" :value entry-id) (input :type "hidden" :name "entry_id" :value entry-id)
(input :type "hidden" :name "count" :value count-val) (input :type "hidden" :name "count" :value count-val)
btn)) btn))
(defcomp ~events-tw-cart-plus () (defcomp ~page/tw-cart-plus ()
(button :type "submit" :class "relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1" (button :type "submit" :class "relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1"
(i :class "fa fa-cart-plus text-2xl" :aria-hidden "true"))) (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true")))
(defcomp ~events-tw-minus () (defcomp ~page/tw-minus ()
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-")) (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))
(defcomp ~events-tw-plus () (defcomp ~page/tw-plus ()
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")) (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+"))
(defcomp ~events-tw-cart-icon (&key qty) (defcomp ~page/tw-cart-icon (&key qty)
(span :class "relative inline-flex items-center justify-center text-emerald-700" (span :class "relative inline-flex items-center justify-center text-emerald-700"
(span :class "relative inline-flex items-center justify-center" (span :class "relative inline-flex items-center justify-center"
(i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true") (i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true")
(span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none" (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
(span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" qty))))) (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" qty)))))
(defcomp ~events-tw-widget (&key entry-id price inner) (defcomp ~page/tw-widget (&key entry-id price inner)
(div :id (str "page-ticket-" entry-id) :class "flex items-center gap-2" (div :id (str "page-ticket-" entry-id) :class "flex items-center gap-2"
(span :class "text-green-600 font-medium text-sm" price) (span :class "text-green-600 font-medium text-sm" price)
inner)) inner))
;; Entry posts panel ;; Entry posts panel
(defcomp ~events-entry-posts-panel (&key posts search-url entry-id) (defcomp ~page/entry-posts-panel (&key posts search-url entry-id)
(div :class "space-y-2" (div :class "space-y-2"
posts posts
(div :class "mt-3 pt-3 border-t" (div :class "mt-3 pt-3 border-t"
@@ -390,13 +390,13 @@
:sx-target (str "#post-search-results-" entry-id) :sx-swap "innerHTML" :name "q") :sx-target (str "#post-search-results-" entry-id) :sx-swap "innerHTML" :name "q")
(div :id (str "post-search-results-" entry-id) :class "mt-2 max-h-96 overflow-y-auto border rounded")))) (div :id (str "post-search-results-" entry-id) :class "mt-2 max-h-96 overflow-y-auto border rounded"))))
(defcomp ~events-entry-posts-list (&key items) (defcomp ~page/entry-posts-list (&key items)
(div :class "space-y-2" items)) (div :class "space-y-2" items))
(defcomp ~events-entry-posts-none () (defcomp ~page/entry-posts-none ()
(p :class "text-sm text-stone-400" "No posts associated")) (p :class "text-sm text-stone-400" "No posts associated"))
(defcomp ~events-entry-post-item (&key img title del-url entry-id csrf-hdr) (defcomp ~page/entry-post-item (&key img title del-url entry-id csrf-hdr)
(div :class "flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border" (div :class "flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border"
img (span :class "text-sm flex-1" title) img (span :class "text-sm flex-1" title)
(button :type "button" :class "text-xs text-red-600 hover:text-red-800 flex-shrink-0" (button :type "button" :class "text-xs text-red-600 hover:text-red-800 flex-shrink-0"
@@ -409,41 +409,41 @@
:sx-headers csrf-hdr :sx-headers csrf-hdr
(i :class "fa fa-times") " Remove"))) (i :class "fa fa-times") " Remove")))
(defcomp ~events-post-img (&key src alt) (defcomp ~page/post-img (&key src alt)
(img :src src :alt alt :class "w-8 h-8 rounded-full object-cover flex-shrink-0")) (img :src src :alt alt :class "w-8 h-8 rounded-full object-cover flex-shrink-0"))
(defcomp ~events-post-img-placeholder () (defcomp ~page/post-img-placeholder ()
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")) (div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))
;; Entry posts nav OOB ;; Entry posts nav OOB
(defcomp ~events-entry-posts-nav-oob-empty () (defcomp ~page/entry-posts-nav-oob-empty ()
(div :id "entry-posts-nav-wrapper" :sx-swap-oob "true")) (div :id "entry-posts-nav-wrapper" :sx-swap-oob "true"))
(defcomp ~events-entry-posts-nav-oob (&key items) (defcomp ~page/entry-posts-nav-oob (&key items)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entry-posts-nav-wrapper" :sx-swap-oob "true" :id "entry-posts-nav-wrapper" :sx-swap-oob "true"
(div :class "flex overflow-x-auto gap-1 scrollbar-thin" items))) (div :class "flex overflow-x-auto gap-1 scrollbar-thin" items)))
(defcomp ~events-entry-nav-post (&key href nav-btn img title) (defcomp ~page/entry-nav-post (&key href nav-btn img title)
(a :href href :class nav-btn img (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title)))) (a :href href :class nav-btn img (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title))))
;; Post nav entries OOB ;; Post nav entries OOB
(defcomp ~events-post-nav-oob-empty () (defcomp ~page/post-nav-oob-empty ()
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true")) (div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
(defcomp ~events-post-nav-entry (&key href nav-btn name time-str) (defcomp ~page/post-nav-entry (&key href nav-btn name time-str)
(a :href href :class nav-btn (a :href href :class nav-btn
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0") (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name) (div :class "font-medium truncate" name)
(div :class "text-xs text-stone-600 truncate" time-str)))) (div :class "text-xs text-stone-600 truncate" time-str))))
(defcomp ~events-post-nav-calendar (&key href nav-btn name) (defcomp ~page/post-nav-calendar (&key href nav-btn name)
(a :href href :class nav-btn (a :href href :class nav-btn
(i :class "fa fa-calendar" :aria-hidden "true") (i :class "fa fa-calendar" :aria-hidden "true")
(div name))) (div name)))
(defcomp ~events-post-nav-wrapper (&key items hyperscript) (defcomp ~page/post-nav-wrapper (&key items hyperscript)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" :sx-swap-oob "true" :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
@@ -461,7 +461,7 @@
(i :class "fa fa-chevron-right")))) (i :class "fa fa-chevron-right"))))
;; Entry nav post link (with image) ;; Entry nav post link (with image)
(defcomp ~events-entry-nav-post-link (&key href img title) (defcomp ~page/entry-nav-post-link (&key href img title)
(a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0" (a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"
img (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title)))) img (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title))))
@@ -471,60 +471,60 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Post image helper from data ;; Post image helper from data
(defcomp ~events-post-img-from-data (&key src alt) (defcomp ~page/post-img-from-data (&key src alt)
(if src (if src
(~events-post-img :src src :alt alt) (~page/post-img :src src :alt alt)
(~events-post-img-placeholder))) (~page/post-img-placeholder)))
;; Entry posts nav OOB from data ;; Entry posts nav OOB from data
(defcomp ~events-entry-posts-nav-oob-from-data (&key nav-btn posts) (defcomp ~page/entry-posts-nav-oob-from-data (&key nav-btn posts)
(if (empty? (or posts (list))) (if (empty? (or posts (list)))
(~events-entry-posts-nav-oob-empty) (~page/entry-posts-nav-oob-empty)
(~events-entry-posts-nav-oob (~page/entry-posts-nav-oob
:items (<> (map (lambda (p) :items (<> (map (lambda (p)
(~events-entry-nav-post (~page/entry-nav-post
:href (get p "href") :nav-btn nav-btn :href (get p "href") :nav-btn nav-btn
:img (~events-post-img-from-data :src (get p "img") :alt (get p "title")) :img (~page/post-img-from-data :src (get p "img") :alt (get p "title"))
:title (get p "title"))) :title (get p "title")))
posts))))) posts)))))
;; Entry posts nav (non-OOB) from data — for desktop nav embedding ;; Entry posts nav (non-OOB) from data — for desktop nav embedding
(defcomp ~events-entry-posts-nav-inner-from-data (&key posts) (defcomp ~page/entry-posts-nav-inner-from-data (&key posts)
(when (not (empty? (or posts (list)))) (when (not (empty? (or posts (list))))
(~events-entry-posts-nav-oob (~page/entry-posts-nav-oob
:items (<> (map (lambda (p) :items (<> (map (lambda (p)
(~events-entry-nav-post-link (~page/entry-nav-post-link
:href (get p "href") :href (get p "href")
:img (~events-post-img-from-data :src (get p "img") :alt (get p "title")) :img (~page/post-img-from-data :src (get p "img") :alt (get p "title"))
:title (get p "title"))) :title (get p "title")))
posts))))) posts)))))
;; Post nav entries+calendars OOB from data ;; Post nav entries+calendars OOB from data
(defcomp ~events-post-nav-wrapper-from-data (&key nav-btn entries calendars hyperscript) (defcomp ~page/post-nav-wrapper-from-data (&key nav-btn entries calendars hyperscript)
(if (and (empty? (or entries (list))) (empty? (or calendars (list)))) (if (and (empty? (or entries (list))) (empty? (or calendars (list))))
(~events-post-nav-oob-empty) (~page/post-nav-oob-empty)
(~events-post-nav-wrapper (~page/post-nav-wrapper
:items (<> :items (<>
(map (lambda (e) (map (lambda (e)
(~events-post-nav-entry (~page/post-nav-entry
:href (get e "href") :nav-btn nav-btn :href (get e "href") :nav-btn nav-btn
:name (get e "name") :time-str (get e "time-str"))) :name (get e "name") :time-str (get e "time-str")))
(or entries (list))) (or entries (list)))
(map (lambda (c) (map (lambda (c)
(~events-post-nav-calendar (~page/post-nav-calendar
:href (get c "href") :nav-btn nav-btn :name (get c "name"))) :href (get c "href") :nav-btn nav-btn :name (get c "name")))
(or calendars (list)))) (or calendars (list))))
:hyperscript hyperscript))) :hyperscript hyperscript)))
;; Entry posts panel from data ;; Entry posts panel from data
(defcomp ~events-entry-posts-panel-from-data (&key entry-id posts search-url) (defcomp ~page/entry-posts-panel-from-data (&key entry-id posts search-url)
(~events-entry-posts-panel (~page/entry-posts-panel
:posts (if (empty? (or posts (list))) :posts (if (empty? (or posts (list)))
(~events-entry-posts-none) (~page/entry-posts-none)
(~events-entry-posts-list (~page/entry-posts-list
:items (<> (map (lambda (p) :items (<> (map (lambda (p)
(~events-entry-post-item (~page/entry-post-item
:img (~events-post-img-from-data :src (get p "img") :alt (get p "title")) :img (~page/post-img-from-data :src (get p "img") :alt (get p "title"))
:title (get p "title") :title (get p "title")
:del-url (get p "del-url") :entry-id entry-id :del-url (get p "del-url") :entry-id entry-id
:csrf-hdr (get p "csrf-hdr"))) :csrf-hdr (get p "csrf-hdr")))
@@ -532,11 +532,11 @@
:search-url search-url :entry-id entry-id)) :search-url search-url :entry-id entry-id))
;; CRUD list/panel from data — shared by calendars + markets ;; CRUD list/panel from data — shared by calendars + markets
(defcomp ~events-crud-list-from-data (&key items empty-msg list-id) (defcomp ~page/crud-list-from-data (&key items empty-msg list-id)
(if (empty? (or items (list))) (if (empty? (or items (list)))
(~empty-state :message empty-msg :cls "text-gray-500 mt-4") (~shared:misc/empty-state :message empty-msg :cls "text-gray-500 mt-4")
(<> (map (lambda (item) (<> (map (lambda (item)
(~crud-item (~shared:misc/crud-item
:href (get item "href") :name (get item "name") :slug (get item "slug") :href (get item "href") :name (get item "name") :slug (get item "slug")
:del-url (get item "del-url") :csrf-hdr (get item "csrf-hdr") :del-url (get item "del-url") :csrf-hdr (get item "csrf-hdr")
:list-id list-id :list-id list-id
@@ -544,84 +544,84 @@
:confirm-text (get item "confirm-text"))) :confirm-text (get item "confirm-text")))
items)))) items))))
(defcomp ~events-crud-panel-from-data (&key can-create create-url csrf errors-id list-id (defcomp ~page/crud-panel-from-data (&key can-create create-url csrf errors-id list-id
placeholder btn-label items empty-msg) placeholder btn-label items empty-msg)
(~crud-panel (~shared:misc/crud-panel
:form (when can-create :form (when can-create
(~crud-create-form (~shared:misc/crud-create-form
:create-url create-url :csrf csrf :errors-id errors-id :create-url create-url :csrf csrf :errors-id errors-id
:list-id list-id :placeholder placeholder :btn-label btn-label)) :list-id list-id :placeholder placeholder :btn-label btn-label))
:list (~events-crud-list-from-data :items items :empty-msg empty-msg :list-id list-id) :list (~page/crud-list-from-data :items items :empty-msg empty-msg :list-id list-id)
:list-id list-id)) :list-id list-id))
;; Post nav admin cog ;; Post nav admin cog
(defcomp ~events-post-nav-admin-cog (&key href aclass) (defcomp ~page/post-nav-admin-cog (&key href aclass)
(div :class "relative nav-group" (div :class "relative nav-group"
(a :href href :class aclass (a :href href :class aclass
(i :class "fa fa-cog" :aria-hidden "true")))) (i :class "fa fa-cog" :aria-hidden "true"))))
;; Post nav from data — calendar links + container nav + admin ;; Post nav from data — calendar links + container nav + admin
(defcomp ~events-post-nav-from-data (&key calendars container-nav select-colours (defcomp ~page/post-nav-from-data (&key calendars container-nav select-colours
has-admin admin-href aclass) has-admin admin-href aclass)
(<> (<>
(map (lambda (c) (map (lambda (c)
(~nav-link :href (get c "href") :icon "fa fa-calendar" (~shared:layout/nav-link :href (get c "href") :icon "fa fa-calendar"
:label (get c "name") :select-colours select-colours :label (get c "name") :select-colours select-colours
:is-selected (get c "is-selected"))) :is-selected (get c "is-selected")))
(or calendars (list))) (or calendars (list)))
(when container-nav container-nav) (when container-nav container-nav)
(when has-admin (when has-admin
(~events-post-nav-admin-cog :href admin-href :aclass aclass)))) (~page/post-nav-admin-cog :href admin-href :aclass aclass))))
;; Calendar nav from data — slots + admin link ;; Calendar nav from data — slots + admin link
(defcomp ~events-calendar-nav-from-data (&key slots-href admin-href select-colours is-admin) (defcomp ~page/calendar-nav-from-data (&key slots-href admin-href select-colours is-admin)
(<> (<>
(~nav-link :href slots-href :icon "fa fa-clock" (~shared:layout/nav-link :href slots-href :icon "fa fa-clock"
:label "Slots" :select-colours select-colours) :label "Slots" :select-colours select-colours)
(when is-admin (when is-admin
(~nav-link :href admin-href :icon "fa fa-cog" (~shared:layout/nav-link :href admin-href :icon "fa fa-cog"
:select-colours select-colours)))) :select-colours select-colours))))
;; Calendar admin nav from data ;; Calendar admin nav from data
(defcomp ~events-calendar-admin-nav-from-data (&key links select-colours) (defcomp ~page/calendar-admin-nav-from-data (&key links select-colours)
(<> (map (lambda (l) (<> (map (lambda (l)
(~nav-link :href (get l "href") :label (get l "label") (~shared:layout/nav-link :href (get l "href") :label (get l "label")
:select-colours select-colours)) :select-colours select-colours))
(or links (list))))) (or links (list)))))
;; Day nav from data — confirmed entries + admin link ;; Day nav from data — confirmed entries + admin link
(defcomp ~events-day-nav-from-data (&key entries is-admin admin-href) (defcomp ~page/day-nav-from-data (&key entries is-admin admin-href)
(<> (<>
(when (not (empty? (or entries (list)))) (when (not (empty? (or entries (list))))
(~events-day-entries-nav (~day/entries-nav
:inner (<> (map (lambda (e) :inner (<> (map (lambda (e)
(~events-day-entry-link (~day/entry-link
:href (get e "href") :name (get e "name") :time-str (get e "time-str"))) :href (get e "href") :name (get e "name") :time-str (get e "time-str")))
entries)))) entries))))
(when is-admin (when is-admin
(~nav-link :href admin-href :icon "fa fa-cog")))) (~shared:layout/nav-link :href admin-href :icon "fa fa-cog"))))
;; Post search results from data ;; Post search results from data
(defcomp ~events-post-search-results-from-data (&key items page next-url has-more) (defcomp ~page/post-search-results-from-data (&key items page next-url has-more)
(<> (<>
(map (lambda (item) (map (lambda (item)
(~events-post-search-item (~forms/post-search-item
:post-url (get item "post-url") :entry-id (get item "entry-id") :post-url (get item "post-url") :entry-id (get item "entry-id")
:csrf (get item "csrf") :post-id (get item "post-id") :csrf (get item "csrf") :post-id (get item "post-id")
:img (~events-post-img-from-data :src (get item "img") :alt (get item "title")) :img (~page/post-img-from-data :src (get item "img") :alt (get item "title"))
:title (get item "title"))) :title (get item "title")))
(or items (list))) (or items (list)))
(cond (cond
(has-more (~events-post-search-sentinel :page page :next-url next-url)) (has-more (~forms/post-search-sentinel :page page :next-url next-url))
((not (empty? (or items (list)))) (~events-post-search-end)) ((not (empty? (or items (list)))) (~forms/post-search-end))
(true "")))) (true ""))))
;; Entry options from data — state-driven button composition ;; Entry options from data — state-driven button composition
(defcomp ~events-entry-options-from-data (&key entry-id state buttons) (defcomp ~page/entry-options-from-data (&key entry-id state buttons)
(~events-entry-options (~admin/entry-options
:entry-id entry-id :entry-id entry-id
:buttons (<> (map (lambda (b) :buttons (<> (map (lambda (b)
(~events-entry-option-button (~admin/entry-option-button
:url (get b "url") :target (str "#calendar_entry_options_" entry-id) :url (get b "url") :target (str "#calendar_entry_options_" entry-id)
:csrf (get b "csrf") :btn-type (get b "btn-type") :csrf (get b "csrf") :btn-type (get b "btn-type")
:action-btn (get b "action-btn") :action-btn (get b "action-btn")

View File

@@ -1,12 +1,12 @@
;; Events payments components ;; Events payments components
(defcomp ~events-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix) (defcomp ~payments/panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix)
(section :class "p-4 max-w-lg mx-auto" (section :class "p-4 max-w-lg mx-auto"
(~sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code (~shared:misc/sumup-settings-form :update-url update-url :csrf csrf :merchant-code merchant-code
:placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured :placeholder placeholder :input-cls input-cls :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :sx-select "#payments-panel"))) :checkout-prefix checkout-prefix :sx-select "#payments-panel")))
(defcomp ~events-markets-create-form (&key create-url csrf) (defcomp ~payments/markets-create-form (&key create-url csrf)
(<> (<>
(div :id "market-create-errors" :class "mt-2 text-sm text-red-600") (div :id "market-create-errors" :class "mt-2 text-sm text-red-600")
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url (form :class "mt-4 flex gap-2 items-end" :sx-post create-url
@@ -20,15 +20,15 @@
:placeholder "e.g. Farm Shop, Bakery")) :placeholder "e.g. Farm Shop, Bakery"))
(button :type "submit" :class "border rounded px-3 py-2" "Add market")))) (button :type "submit" :class "border rounded px-3 py-2" "Add market"))))
(defcomp ~events-markets-panel (&key form list) (defcomp ~payments/markets-panel (&key form list)
(section :class "p-4" (section :class "p-4"
form form
(div :id "markets-list" :class "mt-6" list))) (div :id "markets-list" :class "mt-6" list)))
(defcomp ~events-markets-empty () (defcomp ~payments/markets-empty ()
(p :class "text-gray-500 mt-4" "No markets yet. Create one above.")) (p :class "text-gray-500 mt-4" "No markets yet. Create one above."))
(defcomp ~events-markets-item (&key href market-name market-slug del-url csrf-hdr) (defcomp ~payments/markets-item (&key href market-name market-slug del-url csrf-hdr)
(div :class "mt-6 border rounded-lg p-4" (div :class "mt-6 border rounded-lg p-4"
(div :class "flex items-center justify-between gap-3" (div :class "flex items-center justify-between gap-3"
(a :class "flex items-baseline gap-3" :href href (a :class "flex items-baseline gap-3" :href href

View File

@@ -1,6 +1,6 @@
;; Events ticket components ;; Events ticket components
(defcomp ~events-ticket-card (&key (href :as string) (entry-name :as string) (type-name :as string?) (time-str :as string?) (cal-name :as string?) badge (code-prefix :as string)) (defcomp ~tickets/card (&key (href :as string) (entry-name :as string) (type-name :as string?) (time-str :as string?) (cal-name :as string?) badge (code-prefix :as string))
(a :href href :class "block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition" (a :href href :class "block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition"
(div :class "flex items-start justify-between gap-4" (div :class "flex items-start justify-between gap-4"
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
@@ -12,7 +12,7 @@
badge badge
(span :class "text-xs text-stone-400 font-mono" (str code-prefix "...")))))) (span :class "text-xs text-stone-400 font-mono" (str code-prefix "..."))))))
(defcomp ~events-tickets-panel (&key (list-container :as string) (has-tickets :as boolean) cards) (defcomp ~tickets/panel (&key (list-container :as string) (has-tickets :as boolean) cards)
(section :id "tickets-list" :class list-container (section :id "tickets-list" :class list-container
(h1 :class "text-2xl font-bold mb-6" "My Tickets") (h1 :class "text-2xl font-bold mb-6" "My Tickets")
(if has-tickets (if has-tickets
@@ -22,7 +22,7 @@
(p :class "text-lg" "No tickets yet") (p :class "text-lg" "No tickets yet")
(p :class "text-sm mt-1" "Tickets will appear here after you purchase them."))))) (p :class "text-sm mt-1" "Tickets will appear here after you purchase them.")))))
(defcomp ~events-ticket-detail (&key (list-container :as string) (back-href :as string) (header-bg :as string) (entry-name :as string) badge (defcomp ~tickets/detail (&key (list-container :as string) (back-href :as string) (header-bg :as string) (entry-name :as string) badge
(type-name :as string?) (code :as string) (time-date :as string?) (time-range :as string?) (cal-name :as string?) (type-name :as string?) (code :as string) (time-date :as string?) (time-range :as string?) (cal-name :as string?)
(type-desc :as string?) (checkin-str :as string?) (qr-script :as string)) (type-desc :as string?) (checkin-str :as string?) (qr-script :as string))
(section :id "ticket-detail" :class (str list-container " max-w-lg mx-auto") (section :id "ticket-detail" :class (str list-container " max-w-lg mx-auto")
@@ -54,25 +54,25 @@
(script :src "https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js") (script :src "https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js")
(script qr-script))) (script qr-script)))
(defcomp ~events-ticket-admin-stat (&key (border :as string) (bg :as string) (text-cls :as string) (label-cls :as string) (value :as string) (label :as string)) (defcomp ~tickets/admin-stat (&key (border :as string) (bg :as string) (text-cls :as string) (label-cls :as string) (value :as string) (label :as string))
(div :class (str "rounded-xl border " border " " bg " p-4 text-center") (div :class (str "rounded-xl border " border " " bg " p-4 text-center")
(div :class (str "text-2xl font-bold " text-cls) value) (div :class (str "text-2xl font-bold " text-cls) value)
(div :class (str "text-xs " label-cls " uppercase tracking-wide") label))) (div :class (str "text-xs " label-cls " uppercase tracking-wide") label)))
(defcomp ~events-ticket-admin-date (&key (date-str :as string)) (defcomp ~tickets/admin-date (&key (date-str :as string))
(div :class "text-xs text-stone-500" date-str)) (div :class "text-xs text-stone-500" date-str))
(defcomp ~events-ticket-admin-checkin-form (&key (checkin-url :as string) (code :as string) (csrf :as string)) (defcomp ~tickets/admin-checkin-form (&key (checkin-url :as string) (code :as string) (csrf :as string))
(form :sx-post checkin-url :sx-target (str "#ticket-row-" code) :sx-swap "outerHTML" (form :sx-post checkin-url :sx-target (str "#ticket-row-" code) :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition" (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
(i :class "fa fa-check mr-1" :aria-hidden "true") "Check in"))) (i :class "fa fa-check mr-1" :aria-hidden "true") "Check in")))
(defcomp ~events-ticket-admin-checked-in (&key (time-str :as string)) (defcomp ~tickets/admin-checked-in (&key (time-str :as string))
(span :class "text-xs text-blue-600" (span :class "text-xs text-blue-600"
(i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str))) (i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str)))
(defcomp ~events-ticket-admin-row (&key (code :as string) (code-short :as string) (entry-name :as string) date (type-name :as string) badge action) (defcomp ~tickets/admin-row (&key (code :as string) (code-short :as string) (entry-name :as string) date (type-name :as string) badge action)
(tr :class "hover:bg-stone-50 transition" :id (str "ticket-row-" code) (tr :class "hover:bg-stone-50 transition" :id (str "ticket-row-" code)
(td :class "px-4 py-3" (span :class "font-mono text-xs" code-short)) (td :class "px-4 py-3" (span :class "font-mono text-xs" code-short))
(td :class "px-4 py-3" (div :class "font-medium" entry-name) date) (td :class "px-4 py-3" (div :class "font-medium" entry-name) date)
@@ -80,7 +80,7 @@
(td :class "px-4 py-3" badge) (td :class "px-4 py-3" badge)
(td :class "px-4 py-3" action))) (td :class "px-4 py-3" action)))
(defcomp ~events-ticket-admin-panel (&key (list-container :as string) stats (lookup-url :as string) (has-tickets :as boolean) rows) (defcomp ~tickets/admin-panel (&key (list-container :as string) stats (lookup-url :as string) (has-tickets :as boolean) rows)
(section :id "ticket-admin" :class list-container (section :id "ticket-admin" :class list-container
(h1 :class "text-2xl font-bold mb-6" "Ticket Admin") (h1 :class "text-2xl font-bold mb-6" "Ticket Admin")
(div :class "grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8" stats) (div :class "grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8" stats)
@@ -113,11 +113,11 @@
(tbody :class "divide-y divide-stone-100" rows)) (tbody :class "divide-y divide-stone-100" rows))
(div :class "px-6 py-8 text-center text-stone-500" "No tickets yet")))))) (div :class "px-6 py-8 text-center text-stone-500" "No tickets yet"))))))
(defcomp ~events-checkin-error (&key (message :as string)) (defcomp ~tickets/checkin-error (&key (message :as string))
(div :class "rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800" (div :class "rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"
(i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message)) (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message))
(defcomp ~events-checkin-success-row (&key (code :as string) (code-short :as string) (entry-name :as string) date (type-name :as string) badge (time-str :as string)) (defcomp ~tickets/checkin-success-row (&key (code :as string) (code-short :as string) (entry-name :as string) date (type-name :as string) badge (time-str :as string))
(tr :class "bg-blue-50" :id (str "ticket-row-" code) (tr :class "bg-blue-50" :id (str "ticket-row-" code)
(td :class "px-4 py-3" (span :class "font-mono text-xs" code-short)) (td :class "px-4 py-3" (span :class "font-mono text-xs" code-short))
(td :class "px-4 py-3" (div :class "font-medium" entry-name) date) (td :class "px-4 py-3" (div :class "font-medium" entry-name) date)
@@ -127,65 +127,65 @@
(span :class "text-xs text-blue-600" (span :class "text-xs text-blue-600"
(i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str))))) (i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str)))))
(defcomp ~events-lookup-error (&key (message :as string)) (defcomp ~tickets/lookup-error (&key (message :as string))
(div :class "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800" (div :class "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"
(i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message)) (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message))
(defcomp ~events-lookup-info (&key (entry-name :as string)) (defcomp ~tickets/lookup-info (&key (entry-name :as string))
(div :class "font-semibold text-lg" entry-name)) (div :class "font-semibold text-lg" entry-name))
(defcomp ~events-lookup-type (&key (type-name :as string)) (defcomp ~tickets/lookup-type (&key (type-name :as string))
(div :class "text-sm text-stone-600" type-name)) (div :class "text-sm text-stone-600" type-name))
(defcomp ~events-lookup-date (&key (date-str :as string)) (defcomp ~tickets/lookup-date (&key (date-str :as string))
(div :class "text-sm text-stone-500 mt-1" date-str)) (div :class "text-sm text-stone-500 mt-1" date-str))
(defcomp ~events-lookup-cal (&key (cal-name :as string)) (defcomp ~tickets/lookup-cal (&key (cal-name :as string))
(div :class "text-xs text-stone-400 mt-0.5" cal-name)) (div :class "text-xs text-stone-400 mt-0.5" cal-name))
(defcomp ~events-lookup-status (&key badge (code :as string)) (defcomp ~tickets/lookup-status (&key badge (code :as string))
(div :class "mt-2" badge (span :class "text-xs text-stone-400 ml-2 font-mono" code))) (div :class "mt-2" badge (span :class "text-xs text-stone-400 ml-2 font-mono" code)))
(defcomp ~events-lookup-checkin-time (&key (date-str :as string)) (defcomp ~tickets/lookup-checkin-time (&key (date-str :as string))
(div :class "text-xs text-blue-600 mt-1" (str "Checked in: " date-str))) (div :class "text-xs text-blue-600 mt-1" (str "Checked in: " date-str)))
(defcomp ~events-lookup-checkin-btn (&key (checkin-url :as string) (code :as string) (csrf :as string)) (defcomp ~tickets/lookup-checkin-btn (&key (checkin-url :as string) (code :as string) (csrf :as string))
(form :sx-post checkin-url :sx-target (str "#checkin-action-" code) :sx-swap "innerHTML" (form :sx-post checkin-url :sx-target (str "#checkin-action-" code) :sx-swap "innerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" (button :type "submit"
:class "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg" :class "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg"
(i :class "fa fa-check mr-2" :aria-hidden "true") "Check In"))) (i :class "fa fa-check mr-2" :aria-hidden "true") "Check In")))
(defcomp ~events-lookup-checked-in () (defcomp ~tickets/lookup-checked-in ()
(div :class "text-blue-600 text-center" (div :class "text-blue-600 text-center"
(i :class "fa fa-check-circle text-3xl" :aria-hidden "true") (i :class "fa fa-check-circle text-3xl" :aria-hidden "true")
(div :class "text-sm font-medium mt-1" "Checked In"))) (div :class "text-sm font-medium mt-1" "Checked In")))
(defcomp ~events-lookup-cancelled () (defcomp ~tickets/lookup-cancelled ()
(div :class "text-red-600 text-center" (div :class "text-red-600 text-center"
(i :class "fa fa-times-circle text-3xl" :aria-hidden "true") (i :class "fa fa-times-circle text-3xl" :aria-hidden "true")
(div :class "text-sm font-medium mt-1" "Cancelled"))) (div :class "text-sm font-medium mt-1" "Cancelled")))
(defcomp ~events-lookup-card (&key info (code :as string) action) (defcomp ~tickets/lookup-card (&key info (code :as string) action)
(div :class "rounded-lg border border-stone-200 bg-stone-50 p-4" (div :class "rounded-lg border border-stone-200 bg-stone-50 p-4"
(div :class "flex items-start justify-between gap-4" (div :class "flex items-start justify-between gap-4"
(div :class "flex-1" info) (div :class "flex-1" info)
(div :id (str "checkin-action-" code) action)))) (div :id (str "checkin-action-" code) action))))
(defcomp ~events-entry-tickets-admin-row (&key (code :as string) (code-short :as string) (type-name :as string) badge action) (defcomp ~tickets/entry-tickets-admin-row (&key (code :as string) (code-short :as string) (type-name :as string) badge action)
(tr :class "hover:bg-stone-50" :id (str "entry-ticket-row-" code) (tr :class "hover:bg-stone-50" :id (str "entry-ticket-row-" code)
(td :class "px-4 py-2 font-mono text-xs" code-short) (td :class "px-4 py-2 font-mono text-xs" code-short)
(td :class "px-4 py-2" type-name) (td :class "px-4 py-2" type-name)
(td :class "px-4 py-2" badge) (td :class "px-4 py-2" badge)
(td :class "px-4 py-2" action))) (td :class "px-4 py-2" action)))
(defcomp ~events-entry-tickets-admin-checkin (&key (checkin-url :as string) (code :as string) (csrf :as string)) (defcomp ~tickets/entry-tickets-admin-checkin (&key (checkin-url :as string) (code :as string) (csrf :as string))
(form :sx-post checkin-url :sx-target (str "#entry-ticket-row-" code) :sx-swap "outerHTML" (form :sx-post checkin-url :sx-target (str "#entry-ticket-row-" code) :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700" (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
"Check in"))) "Check in")))
(defcomp ~events-entry-tickets-admin-table (&key rows) (defcomp ~tickets/entry-tickets-admin-table (&key rows)
(div :class "overflow-x-auto rounded-xl border border-stone-200" (div :class "overflow-x-auto rounded-xl border border-stone-200"
(table :class "w-full text-sm" (table :class "w-full text-sm"
(thead :class "bg-stone-50" (thead :class "bg-stone-50"
@@ -195,10 +195,10 @@
(th :class "px-4 py-2 text-left font-medium text-stone-600" "Actions"))) (th :class "px-4 py-2 text-left font-medium text-stone-600" "Actions")))
(tbody :class "divide-y divide-stone-100" rows)))) (tbody :class "divide-y divide-stone-100" rows))))
(defcomp ~events-entry-tickets-admin-empty () (defcomp ~tickets/entry-tickets-admin-empty ()
(div :class "text-center py-6 text-stone-500 text-sm" "No tickets for this entry")) (div :class "text-center py-6 text-stone-500 text-sm" "No tickets for this entry"))
(defcomp ~events-entry-tickets-admin-panel (&key (entry-name :as string) (count-label :as string) body) (defcomp ~tickets/entry-tickets-admin-panel (&key (entry-name :as string) (count-label :as string) body)
(div :class "space-y-4" (div :class "space-y-4"
(div :class "flex items-center justify-between" (div :class "flex items-center justify-between"
(h3 :class "text-lg font-semibold" (str "Tickets for: " entry-name)) (h3 :class "text-lg font-semibold" (str "Tickets for: " entry-name))
@@ -211,72 +211,72 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; My tickets panel from data ;; My tickets panel from data
(defcomp ~events-tickets-panel-from-data (&key (list-container :as string) (tickets :as list?)) (defcomp ~tickets/panel-from-data (&key (list-container :as string) (tickets :as list?))
(~events-tickets-panel (~tickets/panel
:list-container list-container :list-container list-container
:has-tickets (not (empty? (or tickets (list)))) :has-tickets (not (empty? (or tickets (list))))
:cards (<> (map (lambda (t) :cards (<> (map (lambda (t)
(~events-ticket-card (~tickets/card
:href (get t "href") :entry-name (get t "entry-name") :href (get t "href") :entry-name (get t "entry-name")
:type-name (get t "type-name") :time-str (get t "time-str") :type-name (get t "type-name") :time-str (get t "time-str")
:cal-name (get t "cal-name") :cal-name (get t "cal-name")
:badge (~ticket-state-badge :state (get t "state")) :badge (~entries/ticket-state-badge :state (get t "state"))
:code-prefix (get t "code-prefix"))) :code-prefix (get t "code-prefix")))
(or tickets (list)))))) (or tickets (list))))))
;; Ticket detail from data — uses lg badge variant ;; Ticket detail from data — uses lg badge variant
(defcomp ~events-ticket-detail-from-data (&key (list-container :as string) (back-href :as string) (header-bg :as string) (entry-name :as string) (defcomp ~tickets/detail-from-data (&key (list-container :as string) (back-href :as string) (header-bg :as string) (entry-name :as string)
(state :as string) (type-name :as string?) (code :as string) (time-date :as string?) (time-range :as string?) (state :as string) (type-name :as string?) (code :as string) (time-date :as string?) (time-range :as string?)
(cal-name :as string?) (type-desc :as string?) (checkin-str :as string?) (qr-script :as string)) (cal-name :as string?) (type-desc :as string?) (checkin-str :as string?) (qr-script :as string))
(~events-ticket-detail (~tickets/detail
:list-container list-container :back-href back-href :list-container list-container :back-href back-href
:header-bg header-bg :entry-name entry-name :header-bg header-bg :entry-name entry-name
:badge (~ticket-state-badge-lg :state state) :badge (~entries/ticket-state-badge-lg :state state)
:type-name type-name :code code :type-name type-name :code code
:time-date time-date :time-range time-range :time-date time-date :time-range time-range
:cal-name cal-name :type-desc type-desc :cal-name cal-name :type-desc type-desc
:checkin-str checkin-str :qr-script qr-script)) :checkin-str checkin-str :qr-script qr-script))
;; Ticket admin row from data — conditional action column ;; Ticket admin row from data — conditional action column
(defcomp ~events-ticket-admin-row-from-data (&key (code :as string) (code-short :as string) (entry-name :as string) (date-str :as string?) (defcomp ~tickets/admin-row-from-data (&key (code :as string) (code-short :as string) (entry-name :as string) (date-str :as string?)
(type-name :as string) (state :as string) (checkin-url :as string) (csrf :as string) (type-name :as string) (state :as string) (checkin-url :as string) (csrf :as string)
(checked-in-time :as string?)) (checked-in-time :as string?))
(~events-ticket-admin-row (~tickets/admin-row
:code code :code-short code-short :code code :code-short code-short
:entry-name entry-name :entry-name entry-name
:date (when date-str (~events-ticket-admin-date :date-str date-str)) :date (when date-str (~tickets/admin-date :date-str date-str))
:type-name type-name :type-name type-name
:badge (~ticket-state-badge :state state) :badge (~entries/ticket-state-badge :state state)
:action (cond :action (cond
((or (= state "confirmed") (= state "reserved")) ((or (= state "confirmed") (= state "reserved"))
(~events-ticket-admin-checkin-form (~tickets/admin-checkin-form
:checkin-url checkin-url :code code :csrf csrf)) :checkin-url checkin-url :code code :csrf csrf))
((= state "checked_in") ((= state "checked_in")
(~events-ticket-admin-checked-in :time-str (or checked-in-time ""))) (~tickets/admin-checked-in :time-str (or checked-in-time "")))
(true nil)))) (true nil))))
;; Ticket admin panel from data ;; Ticket admin panel from data
(defcomp ~events-ticket-admin-panel-from-data (&key (list-container :as string) (lookup-url :as string) (tickets :as list?) (defcomp ~tickets/admin-panel-from-data (&key (list-container :as string) (lookup-url :as string) (tickets :as list?)
(total :as number?) (confirmed :as number?) (checked-in :as number?) (reserved :as number?)) (total :as number?) (confirmed :as number?) (checked-in :as number?) (reserved :as number?))
(~events-ticket-admin-panel (~tickets/admin-panel
:list-container list-container :list-container list-container
:stats (<> :stats (<>
(~events-ticket-admin-stat :border "border-stone-200" :bg "" (~tickets/admin-stat :border "border-stone-200" :bg ""
:text-cls "text-stone-900" :label-cls "text-stone-500" :text-cls "text-stone-900" :label-cls "text-stone-500"
:value (str (or total 0)) :label "Total") :value (str (or total 0)) :label "Total")
(~events-ticket-admin-stat :border "border-emerald-200" :bg "bg-emerald-50" (~tickets/admin-stat :border "border-emerald-200" :bg "bg-emerald-50"
:text-cls "text-emerald-700" :label-cls "text-emerald-600" :text-cls "text-emerald-700" :label-cls "text-emerald-600"
:value (str (or confirmed 0)) :label "Confirmed") :value (str (or confirmed 0)) :label "Confirmed")
(~events-ticket-admin-stat :border "border-blue-200" :bg "bg-blue-50" (~tickets/admin-stat :border "border-blue-200" :bg "bg-blue-50"
:text-cls "text-blue-700" :label-cls "text-blue-600" :text-cls "text-blue-700" :label-cls "text-blue-600"
:value (str (or checked-in 0)) :label "Checked In") :value (str (or checked-in 0)) :label "Checked In")
(~events-ticket-admin-stat :border "border-amber-200" :bg "bg-amber-50" (~tickets/admin-stat :border "border-amber-200" :bg "bg-amber-50"
:text-cls "text-amber-700" :label-cls "text-amber-600" :text-cls "text-amber-700" :label-cls "text-amber-600"
:value (str (or reserved 0)) :label "Reserved")) :value (str (or reserved 0)) :label "Reserved"))
:lookup-url lookup-url :lookup-url lookup-url
:has-tickets (not (empty? (or tickets (list)))) :has-tickets (not (empty? (or tickets (list))))
:rows (<> (map (lambda (t) :rows (<> (map (lambda (t)
(~events-ticket-admin-row-from-data (~tickets/admin-row-from-data
:code (get t "code") :code-short (get t "code-short") :code (get t "code") :code-short (get t "code-short")
:entry-name (get t "entry-name") :date-str (get t "date-str") :entry-name (get t "entry-name") :date-str (get t "date-str")
:type-name (get t "type-name") :state (get t "state") :type-name (get t "type-name") :state (get t "state")
@@ -285,45 +285,45 @@
(or tickets (list)))))) (or tickets (list))))))
;; Entry tickets admin from data ;; Entry tickets admin from data
(defcomp ~events-entry-tickets-admin-from-data (&key (entry-name :as string) (count-label :as string) (tickets :as list?) (csrf :as string)) (defcomp ~tickets/entry-tickets-admin-from-data (&key (entry-name :as string) (count-label :as string) (tickets :as list?) (csrf :as string))
(~events-entry-tickets-admin-panel (~tickets/entry-tickets-admin-panel
:entry-name entry-name :count-label count-label :entry-name entry-name :count-label count-label
:body (if (empty? (or tickets (list))) :body (if (empty? (or tickets (list)))
(~events-entry-tickets-admin-empty) (~tickets/entry-tickets-admin-empty)
(~events-entry-tickets-admin-table (~tickets/entry-tickets-admin-table
:rows (<> (map (lambda (t) :rows (<> (map (lambda (t)
(~events-entry-tickets-admin-row (~tickets/entry-tickets-admin-row
:code (get t "code") :code-short (get t "code-short") :code (get t "code") :code-short (get t "code-short")
:type-name (get t "type-name") :type-name (get t "type-name")
:badge (~ticket-state-badge :state (get t "state")) :badge (~entries/ticket-state-badge :state (get t "state"))
:action (cond :action (cond
((or (= (get t "state") "confirmed") (= (get t "state") "reserved")) ((or (= (get t "state") "confirmed") (= (get t "state") "reserved"))
(~events-entry-tickets-admin-checkin (~tickets/entry-tickets-admin-checkin
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf)) :checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf))
((= (get t "state") "checked_in") ((= (get t "state") "checked_in")
(~events-ticket-admin-checked-in :time-str (or (get t "checked-in-time") ""))) (~tickets/admin-checked-in :time-str (or (get t "checked-in-time") "")))
(true nil)))) (true nil))))
(or tickets (list)))))))) (or tickets (list))))))))
;; Checkin success row from data ;; Checkin success row from data
(defcomp ~events-checkin-success-row-from-data (&key (code :as string) (code-short :as string) (entry-name :as string) (date-str :as string?) (type-name :as string) (time-str :as string)) (defcomp ~tickets/checkin-success-row-from-data (&key (code :as string) (code-short :as string) (entry-name :as string) (date-str :as string?) (type-name :as string) (time-str :as string))
(~events-checkin-success-row (~tickets/checkin-success-row
:code code :code-short code-short :code code :code-short code-short
:entry-name entry-name :entry-name entry-name
:date (when date-str (~events-ticket-admin-date :date-str date-str)) :date (when date-str (~tickets/admin-date :date-str date-str))
:type-name type-name :type-name type-name
:badge (~ticket-state-badge :state "checked_in") :badge (~entries/ticket-state-badge :state "checked_in")
:time-str time-str)) :time-str time-str))
;; Ticket types table from data ;; Ticket types table from data
(defcomp ~events-ticket-types-table-from-data (&key (list-container :as string) (ticket-types :as list?) (action-btn :as string) (add-url :as string) (defcomp ~tickets/types-table-from-data (&key (list-container :as string) (ticket-types :as list?) (action-btn :as string) (add-url :as string)
(tr-cls :as string) (pill-cls :as string) (hx-select :as string) (csrf-hdr :as string)) (tr-cls :as string) (pill-cls :as string) (hx-select :as string) (csrf-hdr :as string))
(~events-ticket-types-table (~page/ticket-types-table
:list-container list-container :list-container list-container
:rows (if (empty? (or ticket-types (list))) :rows (if (empty? (or ticket-types (list)))
(~events-ticket-types-empty-row) (~page/ticket-types-empty-row)
(<> (map (lambda (tt) (<> (map (lambda (tt)
(~events-ticket-types-row (~page/ticket-types-row
:tr-cls tr-cls :tt-href (get tt "tt-href") :tr-cls tr-cls :tt-href (get tt "tt-href")
:pill-cls pill-cls :hx-select hx-select :pill-cls pill-cls :hx-select hx-select
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str") :tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
@@ -333,23 +333,23 @@
:action-btn action-btn :add-url add-url)) :action-btn action-btn :add-url add-url))
;; Lookup result from data ;; Lookup result from data
(defcomp ~events-lookup-result-from-data (&key (entry-name :as string) (type-name :as string?) (date-str :as string?) (cal-name :as string?) (defcomp ~tickets/lookup-result-from-data (&key (entry-name :as string) (type-name :as string?) (date-str :as string?) (cal-name :as string?)
(state :as string) (code :as string) (checked-in-str :as string?) (state :as string) (code :as string) (checked-in-str :as string?)
(checkin-url :as string) (csrf :as string)) (checkin-url :as string) (csrf :as string))
(~events-lookup-card (~tickets/lookup-card
:info (<> :info (<>
(~events-lookup-info :entry-name entry-name) (~tickets/lookup-info :entry-name entry-name)
(when type-name (~events-lookup-type :type-name type-name)) (when type-name (~tickets/lookup-type :type-name type-name))
(when date-str (~events-lookup-date :date-str date-str)) (when date-str (~tickets/lookup-date :date-str date-str))
(when cal-name (~events-lookup-cal :cal-name cal-name)) (when cal-name (~tickets/lookup-cal :cal-name cal-name))
(~events-lookup-status (~tickets/lookup-status
:badge (~ticket-state-badge :state state) :code code) :badge (~entries/ticket-state-badge :state state) :code code)
(when checked-in-str (when checked-in-str
(~events-lookup-checkin-time :date-str checked-in-str))) (~tickets/lookup-checkin-time :date-str checked-in-str)))
:code code :code code
:action (cond :action (cond
((or (= state "confirmed") (= state "reserved")) ((or (= state "confirmed") (= state "reserved"))
(~events-lookup-checkin-btn :checkin-url checkin-url :code code :csrf csrf)) (~tickets/lookup-checkin-btn :checkin-url checkin-url :code code :csrf csrf))
((= state "checked_in") (~events-lookup-checked-in)) ((= state "checked_in") (~tickets/lookup-checked-in))
((= state "cancelled") (~events-lookup-cancelled)) ((= state "cancelled") (~tickets/lookup-cancelled))
(true nil)))) (true nil))))

View File

@@ -7,8 +7,8 @@
:auth :admin :auth :admin
:layout :events-calendar-admin :layout :events-calendar-admin
:data (calendar-admin-data calendar-slug) :data (calendar-admin-data calendar-slug)
:content (~events-calendar-admin-panel :content (~admin/calendar-admin-panel
:description-content (~events-calendar-description-display :description-content (~calendar/description-display
:description cal-description :edit-url desc-edit-url) :description cal-description :edit-url desc-edit-url)
:csrf csrf :description cal-description)) :csrf csrf :description cal-description))
@@ -18,7 +18,7 @@
:auth :admin :auth :admin
:layout :events-day-admin :layout :events-day-admin
:data (day-admin-data calendar-slug year month day) :data (day-admin-data calendar-slug year month day)
:content (~events-day-admin-panel)) :content (~day/admin-panel))
;; Slots listing ;; Slots listing
(defpage slots-listing (defpage slots-listing
@@ -26,25 +26,25 @@
:auth :public :auth :public
:layout :events-slots :layout :events-slots
:data (slots-data calendar-slug) :data (slots-data calendar-slug)
:content (~events-slots-table :content (~page/slots-table
:list-container list-container :list-container list-container
:rows (if has-slots :rows (if has-slots
(<> (map (fn (s) (<> (map (fn (s)
(~events-slots-row (~page/slots-row
:tr-cls tr-cls :slot-href (get s "slot-href") :tr-cls tr-cls :slot-href (get s "slot-href")
:pill-cls pill-cls :hx-select hx-select :pill-cls pill-cls :hx-select hx-select
:slot-name (get s "name") :description (get s "description") :slot-name (get s "name") :description (get s "description")
:flexible (get s "flexible") :flexible (get s "flexible")
:days (if (get s "has-days") :days (if (get s "has-days")
(~events-slot-days-pills :days-inner (~page/slot-days-pills :days-inner
(<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list")))) (<> (map (fn (d) (~page/slot-day-pill :day d)) (get s "day-list"))))
(~events-slot-no-days)) (~page/slot-no-days))
:time-str (get s "time-str") :time-str (get s "time-str")
:cost-str (get s "cost-str") :action-btn action-btn :cost-str (get s "cost-str") :action-btn action-btn
:del-url (get s "del-url") :del-url (get s "del-url")
:csrf-hdr csrf-hdr)) :csrf-hdr csrf-hdr))
slots-list)) slots-list))
(~events-slots-empty-row)) (~page/slots-empty-row))
:pre-action pre-action :add-url add-url)) :pre-action pre-action :add-url add-url))
;; Slot detail ;; Slot detail
@@ -53,13 +53,13 @@
:auth :admin :auth :admin
:layout :events-slot :layout :events-slot
:data (slot-data calendar-slug slot-id) :data (slot-data calendar-slug slot-id)
:content (~events-slot-panel :content (~page/slot-panel
:slot-id slot-id-str :slot-id slot-id-str
:list-container list-container :list-container list-container
:days (if has-days :days (if has-days
(~events-slot-days-pills :days-inner (~page/slot-days-pills :days-inner
(<> (map (fn (d) (~events-slot-day-pill :day d)) day-list))) (<> (map (fn (d) (~page/slot-day-pill :day d)) day-list)))
(~events-slot-no-days)) (~page/slot-no-days))
:flexible flexible :flexible flexible
:time-str time-str :cost-str cost-str :time-str time-str :cost-str cost-str
:pre-action pre-action :edit-url edit-url)) :pre-action pre-action :edit-url edit-url))
@@ -70,29 +70,29 @@
:auth :admin :auth :admin
:layout :events-entry :layout :events-entry
:data (entry-data calendar-slug entry-id) :data (entry-data calendar-slug entry-id)
:content (~events-entry-panel :content (~admin/entry-panel
:entry-id entry-id-str :list-container list-container :entry-id entry-id-str :list-container list-container
:name (~events-entry-field :label "Name" :name (~admin/entry-field :label "Name"
:content (~events-entry-name-field :name entry-name)) :content (~admin/entry-name-field :name entry-name))
:slot (~events-entry-field :label "Slot" :slot (~admin/entry-field :label "Slot"
:content (if has-slot :content (if has-slot
(~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label) (~admin/entry-slot-assigned :slot-name slot-name :flex-label flex-label)
(~events-entry-slot-none))) (~admin/entry-slot-none)))
:time (~events-entry-field :label "Time Period" :time (~admin/entry-field :label "Time Period"
:content (~events-entry-time-field :time-str time-str)) :content (~admin/entry-time-field :time-str time-str))
:state (~events-entry-field :label "State" :state (~admin/entry-field :label "State"
:content (~events-entry-state-field :entry-id entry-id-str :content (~admin/entry-state-field :entry-id entry-id-str
:badge (~badge :cls state-badge-cls :label state-badge-label))) :badge (~shared:misc/badge :cls state-badge-cls :label state-badge-label)))
:cost (~events-entry-field :label "Cost" :cost (~admin/entry-field :label "Cost"
:content (~events-entry-cost-field :cost cost-str)) :content (~admin/entry-cost-field :cost cost-str))
:tickets (~events-entry-field :label "Tickets" :tickets (~admin/entry-field :label "Tickets"
:content (~events-entry-tickets-field :entry-id entry-id-str :content (~admin/entry-tickets-field :entry-id entry-id-str
:tickets-config tickets-config)) :tickets-config tickets-config))
:buy buy-form :buy buy-form
:date (~events-entry-field :label "Date" :date (~admin/entry-field :label "Date"
:content (~events-entry-date-field :date-str date-str)) :content (~admin/entry-date-field :date-str date-str))
:posts (~events-entry-field :label "Associated Posts" :posts (~admin/entry-field :label "Associated Posts"
:content (~events-entry-posts-field :entry-id entry-id-str :content (~admin/entry-posts-field :entry-id entry-id-str
:posts-panel posts-panel)) :posts-panel posts-panel))
:options options-html :options options-html
:pre-action pre-action :edit-url edit-url) :pre-action pre-action :edit-url edit-url)
@@ -104,9 +104,9 @@
:auth :admin :auth :admin
:layout :events-entry-admin :layout :events-entry-admin
:data (entry-admin-data calendar-slug entry-id year month day) :data (entry-admin-data calendar-slug entry-id year month day)
:content (~nav-link :href ticket-types-href :label "ticket_types" :content (~shared:layout/nav-link :href ticket-types-href :label "ticket_types"
:select-colours select-colours :aclass nav-btn :is-selected false) :select-colours select-colours :aclass nav-btn :is-selected false)
:menu (~events-admin-placeholder-nav)) :menu (~forms/admin-placeholder-nav))
;; Ticket types listing ;; Ticket types listing
(defpage ticket-types-listing (defpage ticket-types-listing
@@ -114,11 +114,11 @@
:auth :public :auth :public
:layout :events-ticket-types :layout :events-ticket-types
:data (ticket-types-data calendar-slug entry-id year month day) :data (ticket-types-data calendar-slug entry-id year month day)
:content (~events-ticket-types-table :content (~page/ticket-types-table
:list-container list-container :list-container list-container
:rows (if has-types :rows (if has-types
(<> (map (fn (tt) (<> (map (fn (tt)
(~events-ticket-types-row (~page/ticket-types-row
:tr-cls tr-cls :tt-href (get tt "tt-href") :tr-cls tr-cls :tt-href (get tt "tt-href")
:pill-cls pill-cls :hx-select hx-select :pill-cls pill-cls :hx-select hx-select
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str") :tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
@@ -126,9 +126,9 @@
:del-url (get tt "del-url") :del-url (get tt "del-url")
:csrf-hdr csrf-hdr)) :csrf-hdr csrf-hdr))
types-list)) types-list))
(~events-ticket-types-empty-row)) (~page/ticket-types-empty-row))
:action-btn action-btn :add-url add-url) :action-btn action-btn :add-url add-url)
:menu (~events-admin-placeholder-nav)) :menu (~forms/admin-placeholder-nav))
;; Ticket type detail ;; Ticket type detail
(defpage ticket-type-detail (defpage ticket-type-detail
@@ -136,13 +136,13 @@
:auth :admin :auth :admin
:layout :events-ticket-type :layout :events-ticket-type
:data (ticket-type-data calendar-slug entry-id ticket-type-id year month day) :data (ticket-type-data calendar-slug entry-id ticket-type-id year month day)
:content (~events-ticket-type-panel :content (~page/ticket-type-panel
:ticket-id ticket-id :list-container list-container :ticket-id ticket-id :list-container list-container
:c1 (~events-ticket-type-col :label "Name" :value tt-name) :c1 (~page/ticket-type-col :label "Name" :value tt-name)
:c2 (~events-ticket-type-col :label "Cost" :value cost-str) :c2 (~page/ticket-type-col :label "Cost" :value cost-str)
:c3 (~events-ticket-type-col :label "Count" :value count-str) :c3 (~page/ticket-type-col :label "Count" :value count-str)
:pre-action pre-action :edit-url edit-url) :pre-action pre-action :edit-url edit-url)
:menu (~events-admin-placeholder-nav)) :menu (~forms/admin-placeholder-nav))
;; My tickets ;; My tickets
(defpage my-tickets (defpage my-tickets
@@ -150,16 +150,16 @@
:auth :public :auth :public
:layout :root :layout :root
:data (tickets-data) :data (tickets-data)
:content (~events-tickets-panel :content (~tickets/panel
:list-container list-container :list-container list-container
:has-tickets has-tickets :has-tickets has-tickets
:cards (when has-tickets :cards (when has-tickets
(<> (map (fn (t) (<> (map (fn (t)
(~events-ticket-card (~tickets/card
:href (get t "href") :entry-name (get t "entry-name") :href (get t "href") :entry-name (get t "entry-name")
:type-name (get t "type-name") :time-str (get t "time-str") :type-name (get t "type-name") :time-str (get t "time-str")
:cal-name (get t "cal-name") :cal-name (get t "cal-name")
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label")) :badge (~shared:misc/badge :cls (get t "badge-cls") :label (get t "badge-label"))
:code-prefix (get t "code-prefix"))) :code-prefix (get t "code-prefix")))
tickets-list))))) tickets-list)))))
@@ -169,7 +169,7 @@
:auth :public :auth :public
:layout :root :layout :root
:data (ticket-detail-data code) :data (ticket-detail-data code)
:content (~events-ticket-detail :content (~tickets/detail
:list-container list-container :back-href back-href :list-container list-container :back-href back-href
:header-bg header-bg :entry-name entry-name :header-bg header-bg :entry-name entry-name
:badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls) :badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls)
@@ -185,10 +185,10 @@
:auth :admin :auth :admin
:layout :root :layout :root
:data (ticket-admin-data) :data (ticket-admin-data)
:content (~events-ticket-admin-panel :content (~tickets/admin-panel
:list-container list-container :list-container list-container
:stats (<> (map (fn (s) :stats (<> (map (fn (s)
(~events-ticket-admin-stat (~tickets/admin-stat
:border (get s "border") :bg (get s "bg") :border (get s "border") :bg (get s "bg")
:text-cls (get s "text-cls") :label-cls (get s "label-cls") :text-cls (get s "text-cls") :label-cls (get s "label-cls")
:value (get s "value") :label (get s "label"))) :value (get s "value") :label (get s "label")))
@@ -196,18 +196,18 @@
:lookup-url lookup-url :has-tickets has-tickets :lookup-url lookup-url :has-tickets has-tickets
:rows (when has-tickets :rows (when has-tickets
(<> (map (fn (t) (<> (map (fn (t)
(~events-ticket-admin-row (~tickets/admin-row
:code (get t "code") :code-short (get t "code-short") :code (get t "code") :code-short (get t "code-short")
:entry-name (get t "entry-name") :entry-name (get t "entry-name")
:date (when (get t "date-str") :date (when (get t "date-str")
(~events-ticket-admin-date :date-str (get t "date-str"))) (~tickets/admin-date :date-str (get t "date-str")))
:type-name (get t "type-name") :type-name (get t "type-name")
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label")) :badge (~shared:misc/badge :cls (get t "badge-cls") :label (get t "badge-label"))
:action (if (get t "can-checkin") :action (if (get t "can-checkin")
(~events-ticket-admin-checkin-form (~tickets/admin-checkin-form
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf) :checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf)
(when (get t "is-checked-in") (when (get t "is-checked-in")
(~events-ticket-admin-checked-in :time-str (get t "checkin-time")))))) (~tickets/admin-checked-in :time-str (get t "checkin-time"))))))
admin-tickets))))) admin-tickets)))))
;; Markets ;; Markets
@@ -216,20 +216,20 @@
:auth :public :auth :public
:layout :events-markets :layout :events-markets
:data (markets-data) :data (markets-data)
:content (~crud-panel :content (~shared:misc/crud-panel
:list-id "markets-list" :list-id "markets-list"
:form (when can-create :form (when can-create
(~crud-create-form :create-url create-url :csrf csrf (~shared:misc/crud-create-form :create-url create-url :csrf csrf
:errors-id "market-create-errors" :list-id "markets-list" :errors-id "market-create-errors" :list-id "markets-list"
:placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market")) :placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market"))
:list (if markets-list :list (if markets-list
(<> (map (fn (m) (<> (map (fn (m)
(~crud-item :href (get m "href") :name (get m "name") (~shared:misc/crud-item :href (get m "href") :name (get m "name")
:slug (get m "slug") :del-url (get m "del-url") :slug (get m "slug") :del-url (get m "del-url")
:csrf-hdr (get m "csrf-hdr") :csrf-hdr (get m "csrf-hdr")
:list-id "markets-list" :list-id "markets-list"
:confirm-title "Delete market?" :confirm-title "Delete market?"
:confirm-text "Products will be hidden (soft delete)")) :confirm-text "Products will be hidden (soft delete)"))
markets-list)) markets-list))
(~empty-state :message "No markets yet. Create one above." (~shared:misc/empty-state :message "No markets yet. Create one above."
:cls "text-gray-500 mt-4")))) :cls "text-gray-500 mt-4"))))

View File

@@ -44,7 +44,7 @@ async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets,
ctx, entries, has_more, pending_tickets, page_info, ctx, entries, has_more, pending_tickets, page_info,
page, view, ticket_url, next_url, events_url, page, view, ticket_url, next_url, events_url,
) )
hdr = await render_to_sx_with_env("layout-root-full", {}) hdr = await render_to_sx_with_env("shared:layout/root-full", {})
return await full_page_sx(ctx, header_rows=hdr, content=content) return await full_page_sx(ctx, header_rows=hdr, content=content)
@@ -105,7 +105,7 @@ async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets
is_page_scoped=True, post=post, is_page_scoped=True, post=post,
) )
hdr = await render_to_sx_with_env("layout-root-full", {}) hdr = await render_to_sx_with_env("shared:layout/root-full", {})
hdr += await header_child_sx(await _post_header_sx(ctx)) hdr += await header_child_sx(await _post_header_sx(ctx))
return await full_page_sx(ctx, header_rows=hdr, content=content) return await full_page_sx(ctx, header_rows=hdr, content=content)
@@ -160,7 +160,7 @@ async def render_calendars_page(ctx: dict) -> str:
content = _calendars_main_panel_sx(ctx) content = _calendars_main_panel_sx(ctx)
ctx = await _ensure_container_nav(ctx) ctx = await _ensure_container_nav(ctx)
slug = (ctx.get("post") or {}).get("slug", "") slug = (ctx.get("post") or {}).get("slug", "")
root_hdr = await render_to_sx_with_env("layout-root-full", {}) root_hdr = await render_to_sx_with_env("shared:layout/root-full", {})
post_hdr = await _post_header_sx(ctx) post_hdr = await _post_header_sx(ctx)
admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars") admin_hdr = await post_admin_header_sx(ctx, slug, selected="calendars")
return await full_page_sx(ctx, header_rows=root_hdr + post_hdr + admin_hdr, content=content) return await full_page_sx(ctx, header_rows=root_hdr + post_hdr + admin_hdr, content=content)
@@ -183,7 +183,7 @@ async def render_calendars_oob(ctx: dict) -> str:
async def render_calendar_page(ctx: dict) -> str: async def render_calendar_page(ctx: dict) -> str:
"""Full page: calendar month view.""" """Full page: calendar month view."""
content = _calendar_main_panel_html(ctx) content = _calendar_main_panel_html(ctx)
hdr = await render_to_sx_with_env("layout-root-full", {}) hdr = await render_to_sx_with_env("shared:layout/root-full", {})
child = await _post_header_sx(ctx) + _calendar_header_sx(ctx) child = await _post_header_sx(ctx) + _calendar_header_sx(ctx)
hdr += await header_child_sx(child) hdr += await header_child_sx(child)
return await full_page_sx(ctx, header_rows=hdr, content=content) return await full_page_sx(ctx, header_rows=hdr, content=content)
@@ -206,7 +206,7 @@ async def render_calendar_oob(ctx: dict) -> str:
async def render_day_page(ctx: dict) -> str: async def render_day_page(ctx: dict) -> str:
"""Full page: day detail.""" """Full page: day detail."""
content = _day_main_panel_html(ctx) content = _day_main_panel_html(ctx)
hdr = await render_to_sx_with_env("layout-root-full", {}) hdr = await render_to_sx_with_env("shared:layout/root-full", {})
child = (await _post_header_sx(ctx) child = (await _post_header_sx(ctx)
+ _calendar_header_sx(ctx) + _day_header_sx(ctx)) + _calendar_header_sx(ctx) + _day_header_sx(ctx))
hdr += await header_child_sx(child) hdr += await header_child_sx(child)

View File

@@ -117,7 +117,7 @@ def _cart_icon_oob(count: int) -> str:
def _cart_icon_ctx(count: int) -> dict: def _cart_icon_ctx(count: int) -> dict:
"""Return data dict for the ~events-cart-icon component.""" """Return data dict for the ~page/cart-icon component."""
from quart import g from quart import g
blog_url_fn = getattr(g, "blog_url", None) blog_url_fn = getattr(g, "blog_url", None)

View File

@@ -1,7 +1,7 @@
;; Auth components (choose username — federation-specific) ;; Auth components (choose username — federation-specific)
;; Login and check-email components are shared: see shared/sx/templates/auth.sx ;; Login and check-email components are shared: see shared/sx/templates/auth.sx
(defcomp ~federation-choose-username (&key (domain :as string) error (csrf :as string) (username :as string) (check-url :as string)) (defcomp ~auth/choose-username (&key (domain :as string) error (csrf :as string) (username :as string) (check-url :as string))
(div :class "py-8 max-w-md mx-auto" (div :class "py-8 max-w-md mx-auto"
(h1 :class "text-2xl font-bold mb-2" "Choose your username") (h1 :class "text-2xl font-bold mb-2" "Choose your username")
(p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: " (p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "

View File

@@ -12,7 +12,7 @@
(let ((actor (service "federation" "get-actor-by-username" :username u))) (let ((actor (service "federation" "get-actor-by-username" :username u)))
(<> (str "<!-- fragment:" u " -->") (<> (str "<!-- fragment:" u " -->")
(when (not (nil? actor)) (when (not (nil? actor))
(~link-card (~shared:fragments/link-card
:link (app-url "federation" :link (app-url "federation"
(str "/users/" (get actor "preferred_username"))) (str "/users/" (get actor "preferred_username")))
:title (or (get actor "display_name") :title (or (get actor "display_name")
@@ -28,7 +28,7 @@
(let ((actor (service "federation" "get-actor-by-username" (let ((actor (service "federation" "get-actor-by-username"
:username lookup))) :username lookup)))
(when (not (nil? actor)) (when (not (nil? actor))
(~link-card (~shared:fragments/link-card
:link (app-url "federation" :link (app-url "federation"
(str "/users/" (get actor "preferred_username"))) (str "/users/" (get actor "preferred_username")))
:title (or (get actor "display_name") :title (or (get actor "display_name")

View File

@@ -2,16 +2,16 @@
;; Registered via register_sx_layout("social", ...) in __init__.py. ;; Registered via register_sx_layout("social", ...) in __init__.py.
;; Full page: root header + social header in header-child ;; Full page: root header + social header in header-child
(defcomp ~social-layout-full () (defcomp ~layouts/social-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (~federation-social-header :inner (~social/header
:nav (~federation-social-nav :actor (federation-actor-ctx)))))) :nav (~social/nav :actor (federation-actor-ctx))))))
;; OOB (HTMX): social header oob + root header oob ;; OOB (HTMX): social header oob + root header oob
(defcomp ~social-layout-oob () (defcomp ~layouts/social-layout-oob ()
(<> (~oob-header-sx (<> (~shared:layout/oob-header-sx
:parent-id "root-header-child" :parent-id "root-header-child"
:row (~federation-social-header :row (~social/header
:nav (~federation-social-nav :actor (federation-actor-ctx)))) :nav (~social/nav :actor (federation-actor-ctx))))
(~root-header-auto true))) (~root-header-auto true)))

View File

@@ -1,9 +1,9 @@
;; Notification components ;; Notification components
(defcomp ~federation-notification-preview (&key (preview :as string)) (defcomp ~notifications/preview (&key (preview :as string))
(div :class "text-sm text-stone-500 mt-1 truncate" preview)) (div :class "text-sm text-stone-500 mt-1 truncate" preview))
(defcomp ~federation-notification-card (&key (cls :as string) avatar (from-name :as string) (from-username :as string) (from-domain :as string) (action-text :as string) preview (time :as string)) (defcomp ~notifications/card (&key (cls :as string) avatar (from-name :as string) (from-username :as string) (from-domain :as string) (action-text :as string) preview (time :as string))
(div :class cls (div :class cls
(div :class "flex items-start gap-3" (div :class "flex items-start gap-3"
avatar avatar
@@ -15,14 +15,14 @@
preview preview
(div :class "text-xs text-stone-400 mt-1" time))))) (div :class "text-xs text-stone-400 mt-1" time)))))
(defcomp ~federation-notifications-list (&key (items :as list)) (defcomp ~notifications/list (&key (items :as list))
(div :class "space-y-2" items)) (div :class "space-y-2" items))
(defcomp ~federation-notifications-page (&key notifs) (defcomp ~notifications/page (&key notifs)
(h1 :class "text-2xl font-bold mb-6" "Notifications") notifs) (h1 :class "text-2xl font-bold mb-6" "Notifications") notifs)
;; Assembled notification card — replaces Python _notification_sx ;; Assembled notification card — replaces Python _notification_sx
(defcomp ~federation-notification-from-data (&key (notif :as dict)) (defcomp ~notifications/from-data (&key (notif :as dict))
(let* ((from-name (or (get notif "from_actor_name") "?")) (let* ((from-name (or (get notif "from_actor_name") "?"))
(from-username (or (get notif "from_actor_username") "")) (from-username (or (get notif "from_actor_username") ""))
(from-domain (or (get notif "from_actor_domain") "")) (from-domain (or (get notif "from_actor_domain") ""))
@@ -44,9 +44,9 @@
((= ntype "mention") "mentioned you") ((= ntype "mention") "mentioned you")
((= ntype "reply") "replied to your post") ((= ntype "reply") "replied to your post")
(true "")))) (true ""))))
(~federation-notification-card (~notifications/card
:cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border) :cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border)
:avatar (~avatar :avatar (~shared:misc/avatar
:src from-icon :src from-icon
:cls (if from-icon "w-8 h-8 rounded-full" :cls (if from-icon "w-8 h-8 rounded-full"
"w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs") "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs")
@@ -55,15 +55,15 @@
:from-username (escape from-username) :from-username (escape from-username)
:from-domain (if from-domain (str "@" (escape from-domain)) "") :from-domain (if from-domain (str "@" (escape from-domain)) "")
:action-text action-text :action-text action-text
:preview (when preview (~federation-notification-preview :preview (escape preview))) :preview (when preview (~notifications/preview :preview (escape preview)))
:time created))) :time created)))
;; Assembled notifications content — replaces Python _notifications_content_sx ;; Assembled notifications content — replaces Python _notifications_content_sx
(defcomp ~federation-notifications-content (&key (notifications :as list)) (defcomp ~notifications/content (&key (notifications :as list))
(~federation-notifications-page (~notifications/page
:notifs (if (empty? notifications) :notifs (if (empty? notifications)
(~empty-state :message "No notifications yet." :cls "text-stone-500") (~shared:misc/empty-state :message "No notifications yet." :cls "text-stone-500")
(~federation-notifications-list (~notifications/list
:items (map (lambda (n) :items (map (lambda (n)
(~federation-notification-from-data :notif n)) (~notifications/from-data :notif n))
notifications))))) notifications)))))

View File

@@ -1,6 +1,6 @@
;; Profile and actor timeline components ;; Profile and actor timeline components
(defcomp ~federation-actor-profile-header (&key avatar (display-name :as string) (username :as string) (domain :as string) summary follow) (defcomp ~profile/actor-profile-header (&key avatar (display-name :as string) (username :as string) (domain :as string) summary follow)
(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6" (div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"
(div :class "flex items-center gap-4" (div :class "flex items-center gap-4"
avatar avatar
@@ -10,39 +10,39 @@
summary) summary)
follow))) follow)))
(defcomp ~federation-actor-timeline-layout (&key header timeline) (defcomp ~profile/actor-timeline-layout (&key header timeline)
header header
(div :id "timeline" timeline)) (div :id "timeline" timeline))
(defcomp ~federation-follow-form (&key (action :as string) (csrf :as string) (actor-url :as string) (label :as string) (cls :as string)) (defcomp ~profile/follow-form (&key (action :as string) (csrf :as string) (actor-url :as string) (label :as string) (cls :as string))
(div :class "flex-shrink-0" (div :class "flex-shrink-0"
(form :method "post" :action action (form :method "post" :action action
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "actor_url" :value actor-url) (input :type "hidden" :name "actor_url" :value actor-url)
(button :type "submit" :class cls label)))) (button :type "submit" :class cls label))))
(defcomp ~federation-profile-summary (&key (summary :as string)) (defcomp ~profile/summary (&key (summary :as string))
(div :class "text-sm text-stone-600 mt-2" (~rich-text :html summary))) (div :class "text-sm text-stone-600 mt-2" (~rich-text :html summary)))
;; Public profile page ;; Public profile page
(defcomp ~federation-activity-obj-type (&key (obj-type :as string)) (defcomp ~profile/activity-obj-type (&key (obj-type :as string))
(span :class "text-sm text-stone-500" obj-type)) (span :class "text-sm text-stone-500" obj-type))
(defcomp ~federation-activity-card (&key (activity-type :as string) (published :as string) obj-type) (defcomp ~profile/activity-card (&key (activity-type :as string) (published :as string) obj-type)
(div :class "bg-white rounded-lg shadow p-4" (div :class "bg-white rounded-lg shadow p-4"
(div :class "flex justify-between items-start" (div :class "flex justify-between items-start"
(span :class "font-medium" activity-type) (span :class "font-medium" activity-type)
(span :class "text-sm text-stone-400" published)) (span :class "text-sm text-stone-400" published))
obj-type)) obj-type))
(defcomp ~federation-activities-list (&key (items :as list)) (defcomp ~profile/activities-list (&key (items :as list))
(div :class "space-y-4" items)) (div :class "space-y-4" items))
(defcomp ~federation-activities-empty () (defcomp ~profile/activities-empty ()
(p :class "text-stone-500" "No activities yet.")) (p :class "text-stone-500" "No activities yet."))
(defcomp ~federation-profile-page (&key (display-name :as string) (username :as string) (domain :as string) summary (activities-heading :as string) activities) (defcomp ~profile/page (&key (display-name :as string) (username :as string) (domain :as string) summary (activities-heading :as string) activities)
(div :class "py-8" (div :class "py-8"
(div :class "bg-white rounded-lg shadow p-6 mb-6" (div :class "bg-white rounded-lg shadow p-6 mb-6"
(h1 :class "text-2xl font-bold" display-name) (h1 :class "text-2xl font-bold" display-name)
@@ -51,11 +51,11 @@
(h2 :class "text-xl font-bold mb-4" activities-heading) (h2 :class "text-xl font-bold mb-4" activities-heading)
activities)) activities))
(defcomp ~federation-profile-summary-text (&key (text :as string)) (defcomp ~profile/summary-text (&key (text :as string))
(p :class "mt-2" text)) (p :class "mt-2" text))
;; Assembled actor timeline content — replaces Python _actor_timeline_content_sx ;; Assembled actor timeline content — replaces Python _actor_timeline_content_sx
(defcomp ~federation-actor-timeline-content (&key (remote-actor :as dict) (items :as list) (is-following :as boolean) actor) (defcomp ~profile/actor-timeline-content (&key (remote-actor :as dict) (items :as list) (is-following :as boolean) actor)
(let* ((display-name (or (get remote-actor "display_name") (get remote-actor "preferred_username") "")) (let* ((display-name (or (get remote-actor "display_name") (get remote-actor "preferred_username") ""))
(icon-url (get remote-actor "icon_url")) (icon-url (get remote-actor "icon_url"))
(summary (get remote-actor "summary")) (summary (get remote-actor "summary"))
@@ -63,9 +63,9 @@
(csrf (csrf-token)) (csrf (csrf-token))
(initial (if (and (not icon-url) display-name) (initial (if (and (not icon-url) display-name)
(upper (slice display-name 0 1)) "?"))) (upper (slice display-name 0 1)) "?")))
(~federation-actor-timeline-layout (~profile/actor-timeline-layout
:header (~federation-actor-profile-header :header (~profile/actor-profile-header
:avatar (~avatar :avatar (~shared:misc/avatar
:src icon-url :src icon-url
:cls (if icon-url "w-16 h-16 rounded-full" :cls (if icon-url "w-16 h-16 rounded-full"
"w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl") "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl")
@@ -73,18 +73,18 @@
:display-name (escape display-name) :display-name (escape display-name)
:username (escape (or (get remote-actor "preferred_username") "")) :username (escape (or (get remote-actor "preferred_username") ""))
:domain (escape (or (get remote-actor "domain") "")) :domain (escape (or (get remote-actor "domain") ""))
:summary (when summary (~federation-profile-summary :summary summary)) :summary (when summary (~profile/summary :summary summary))
:follow (when actor :follow (when actor
(if is-following (if is-following
(~federation-follow-form (~profile/follow-form
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url :action (url-for "social.unfollow") :csrf csrf :actor-url actor-url
:label "Unfollow" :label "Unfollow"
:cls "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100") :cls "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100")
(~federation-follow-form (~profile/follow-form
:action (url-for "social.follow") :csrf csrf :actor-url actor-url :action (url-for "social.follow") :csrf csrf :actor-url actor-url
:label "Follow" :label "Follow"
:cls "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700")))) :cls "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"))))
:timeline (~federation-timeline-items :timeline (~social/timeline-items
:items items :timeline-type "actor" :actor actor :items items :timeline-type "actor" :actor actor
:next-url (when (not (empty? items)) :next-url (when (not (empty? items))
(url-for "social.actor_timeline_page" (url-for "social.actor_timeline_page"
@@ -92,14 +92,14 @@
:before (get (last items) "before_cursor"))))))) :before (get (last items) "before_cursor")))))))
;; Data-driven activities list (replaces Python loop in render_profile_page) ;; Data-driven activities list (replaces Python loop in render_profile_page)
(defcomp ~federation-activities-from-data (&key (activities :as list)) (defcomp ~profile/activities-from-data (&key (activities :as list))
(if (empty? (or activities (list))) (if (empty? (or activities (list)))
(~federation-activities-empty) (~profile/activities-empty)
(~federation-activities-list (~profile/activities-list
:items (<> (map (lambda (a) :items (<> (map (lambda (a)
(~federation-activity-card (~profile/activity-card
:activity-type (get a "activity_type") :activity-type (get a "activity_type")
:published (get a "published") :published (get a "published")
:obj-type (when (get a "object_type") :obj-type (when (get a "object_type")
(~federation-activity-obj-type :obj-type (get a "object_type"))))) (~profile/activity-obj-type :obj-type (get a "object_type")))))
activities))))) activities)))))

View File

@@ -1,37 +1,37 @@
;; Search and actor card components ;; Search and actor card components
;; Aliases — delegate to shared ~avatar ;; Aliases — delegate to shared ~shared:misc/avatar
(defcomp ~federation-actor-avatar-img (&key (src :as string) (cls :as string)) (defcomp ~search/actor-avatar-img (&key (src :as string) (cls :as string))
(~avatar :src src :cls cls)) (~shared:misc/avatar :src src :cls cls))
(defcomp ~federation-actor-avatar-placeholder (&key (cls :as string) (initial :as string)) (defcomp ~search/actor-avatar-placeholder (&key (cls :as string) (initial :as string))
(~avatar :cls cls :initial initial)) (~shared:misc/avatar :cls cls :initial initial))
(defcomp ~federation-actor-name-link (&key (href :as string) (name :as string)) (defcomp ~search/actor-name-link (&key (href :as string) (name :as string))
(a :href href :class "font-semibold text-stone-900 hover:underline" name)) (a :href href :class "font-semibold text-stone-900 hover:underline" name))
(defcomp ~federation-actor-name-link-external (&key (href :as string) (name :as string)) (defcomp ~search/actor-name-link-external (&key (href :as string) (name :as string))
(a :href href :target "_blank" :rel "noopener" (a :href href :target "_blank" :rel "noopener"
:class "font-semibold text-stone-900 hover:underline" name)) :class "font-semibold text-stone-900 hover:underline" name))
(defcomp ~federation-actor-summary (&key (summary :as string)) (defcomp ~search/actor-summary (&key (summary :as string))
(div :class "text-sm text-stone-600 mt-1 truncate" (~rich-text :html summary))) (div :class "text-sm text-stone-600 mt-1 truncate" (~rich-text :html summary)))
(defcomp ~federation-unfollow-button (&key (action :as string) (csrf :as string) (actor-url :as string)) (defcomp ~search/unfollow-button (&key (action :as string) (csrf :as string) (actor-url :as string))
(div :class "flex-shrink-0" (div :class "flex-shrink-0"
(form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML" (form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "actor_url" :value actor-url) (input :type "hidden" :name "actor_url" :value actor-url)
(button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow")))) (button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow"))))
(defcomp ~federation-follow-button (&key (action :as string) (csrf :as string) (actor-url :as string) (label :as string)) (defcomp ~search/follow-button (&key (action :as string) (csrf :as string) (actor-url :as string) (label :as string))
(div :class "flex-shrink-0" (div :class "flex-shrink-0"
(form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML" (form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :name "actor_url" :value actor-url) (input :type "hidden" :name "actor_url" :value actor-url)
(button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" label)))) (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" label))))
(defcomp ~federation-actor-card (&key (cls :as string) (id :as string) avatar name (username :as string) (domain :as string) summary button) (defcomp ~search/actor-card (&key (cls :as string) (id :as string) avatar name (username :as string) (domain :as string) summary button)
(article :class cls :id id (article :class cls :id id
avatar avatar
(div :class "flex-1 min-w-0" (div :class "flex-1 min-w-0"
@@ -41,7 +41,7 @@
button)) button))
;; Data-driven actor card (replaces Python _actor_card_sx loop) ;; Data-driven actor card (replaces Python _actor_card_sx loop)
(defcomp ~federation-actor-card-from-data (&key (d :as dict) (has-actor :as boolean) (csrf :as string) (follow-url :as string) (unfollow-url :as string) (list-type :as string)) (defcomp ~search/actor-card-from-data (&key (d :as dict) (has-actor :as boolean) (csrf :as string) (follow-url :as string) (unfollow-url :as string) (list-type :as string))
(let* ((icon-url (get d "icon_url")) (let* ((icon-url (get d "icon_url"))
(display-name (get d "display_name")) (display-name (get d "display_name"))
(username (get d "username")) (username (get d "username"))
@@ -49,42 +49,42 @@
(actor-url (get d "actor_url")) (actor-url (get d "actor_url"))
(safe-id (get d "safe_id")) (safe-id (get d "safe_id"))
(initial (or (get d "initial") "?")) (initial (or (get d "initial") "?"))
(avatar (~avatar (avatar (~shared:misc/avatar
:src icon-url :src icon-url
:cls (if icon-url "w-12 h-12 rounded-full" :cls (if icon-url "w-12 h-12 rounded-full"
"w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold") "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold")
:initial (when (not icon-url) initial))) :initial (when (not icon-url) initial)))
(name-sx (if (get d "external_link") (name-sx (if (get d "external_link")
(~federation-actor-name-link-external :href (get d "name_href") :name display-name) (~search/actor-name-link-external :href (get d "name_href") :name display-name)
(~federation-actor-name-link :href (get d "name_href") :name display-name))) (~search/actor-name-link :href (get d "name_href") :name display-name)))
(summary-sx (when (get d "summary") (summary-sx (when (get d "summary")
(~federation-actor-summary :summary (get d "summary")))) (~search/actor-summary :summary (get d "summary"))))
(is-followed (get d "is_followed")) (is-followed (get d "is_followed"))
(button (when has-actor (button (when has-actor
(if (or (= list-type "following") is-followed) (if (or (= list-type "following") is-followed)
(~federation-unfollow-button :action unfollow-url :csrf csrf :actor-url actor-url) (~search/unfollow-button :action unfollow-url :csrf csrf :actor-url actor-url)
(~federation-follow-button :action follow-url :csrf csrf :actor-url actor-url (~search/follow-button :action follow-url :csrf csrf :actor-url actor-url
:label (if (= list-type "followers") "Follow Back" "Follow")))))) :label (if (= list-type "followers") "Follow Back" "Follow"))))))
(~federation-actor-card (~search/actor-card
:cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" :cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
:id (str "actor-" safe-id) :id (str "actor-" safe-id)
:avatar avatar :name name-sx :username username :domain domain :avatar avatar :name name-sx :username username :domain domain
:summary summary-sx :button button))) :summary summary-sx :button button)))
;; Data-driven actor list (replaces Python _search_results_sx / _actor_list_items_sx loops) ;; Data-driven actor list (replaces Python _search_results_sx / _actor_list_items_sx loops)
(defcomp ~federation-actor-list-from-data (&key (actors :as list) (next-url :as string?) (has-actor :as boolean) (csrf :as string) (defcomp ~search/actor-list-from-data (&key (actors :as list) (next-url :as string?) (has-actor :as boolean) (csrf :as string)
(follow-url :as string) (unfollow-url :as string) (list-type :as string)) (follow-url :as string) (unfollow-url :as string) (list-type :as string))
(<> (<>
(map (lambda (d) (map (lambda (d)
(~federation-actor-card-from-data :d d :has-actor has-actor :csrf csrf (~search/actor-card-from-data :d d :has-actor has-actor :csrf csrf
:follow-url follow-url :unfollow-url unfollow-url :list-type list-type)) :follow-url follow-url :unfollow-url unfollow-url :list-type list-type))
(or actors (list))) (or actors (list)))
(when next-url (~federation-scroll-sentinel :url next-url)))) (when next-url (~social/scroll-sentinel :url next-url))))
(defcomp ~federation-search-info (&key (cls :as string) (text :as string)) (defcomp ~search/info (&key (cls :as string) (text :as string))
(p :class cls text)) (p :class cls text))
(defcomp ~federation-search-page (&key (search-url :as string) (search-page-url :as string) (query :as string) info results) (defcomp ~search/page (&key (search-url :as string) (search-page-url :as string) (query :as string) info results)
(h1 :class "text-2xl font-bold mb-6" "Search") (h1 :class "text-2xl font-bold mb-6" "Search")
(form :method "get" :action search-url :class "mb-6" (form :method "get" :action search-url :class "mb-6"
:sx-get search-page-url :sx-target "#search-results" :sx-push-url search-url :sx-get search-page-url :sx-target "#search-results" :sx-push-url search-url
@@ -97,7 +97,7 @@
(div :id "search-results" results)) (div :id "search-results" results))
;; Following / Followers list page ;; Following / Followers list page
(defcomp ~federation-actor-list-page (&key (title :as string) (count-str :as string) items) (defcomp ~search/actor-list-page (&key (title :as string) (count-str :as string) items)
(h1 :class "text-2xl font-bold mb-6" title " " (h1 :class "text-2xl font-bold mb-6" title " "
(span :class "text-stone-400 font-normal" count-str)) (span :class "text-stone-400 font-normal" count-str))
(div :id "actor-list" items)) (div :id "actor-list" items))
@@ -106,7 +106,7 @@
;; Assembled actor card — replaces Python _actor_card_sx ;; Assembled actor card — replaces Python _actor_card_sx
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~federation-actor-card-from-data (&key (a :as dict) actor (followed-urls :as list) (list-type :as string)) (defcomp ~search/actor-card-from-data (&key (a :as dict) actor (followed-urls :as list) (list-type :as string))
(let* ((display-name (or (get a "display_name") (get a "preferred_username") "")) (let* ((display-name (or (get a "display_name") (get a "preferred_username") ""))
(username (or (get a "preferred_username") "")) (username (or (get a "preferred_username") ""))
(domain (or (get a "domain") "")) (domain (or (get a "domain") ""))
@@ -119,81 +119,81 @@
(upper (slice (or display-name username) 0 1)) "?")) (upper (slice (or display-name username) 0 1)) "?"))
(csrf (csrf-token)) (csrf (csrf-token))
(is-followed (contains? (or followed-urls (list)) actor-url))) (is-followed (contains? (or followed-urls (list)) actor-url)))
(~federation-actor-card (~search/actor-card
:cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" :cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
:id (str "actor-" safe-id) :id (str "actor-" safe-id)
:avatar (~avatar :avatar (~shared:misc/avatar
:src icon-url :src icon-url
:cls (if icon-url "w-12 h-12 rounded-full" :cls (if icon-url "w-12 h-12 rounded-full"
"w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold") "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold")
:initial (when (not icon-url) initial)) :initial (when (not icon-url) initial))
:name (if (and (or (= list-type "following") (= list-type "search")) aid) :name (if (and (or (= list-type "following") (= list-type "search")) aid)
(~federation-actor-name-link (~search/actor-name-link
:href (url-for "social.defpage_actor_timeline" :id aid) :href (url-for "social.defpage_actor_timeline" :id aid)
:name (escape display-name)) :name (escape display-name))
(~federation-actor-name-link-external (~search/actor-name-link-external
:href (str "https://" domain "/@" username) :href (str "https://" domain "/@" username)
:name (escape display-name))) :name (escape display-name)))
:username (escape username) :username (escape username)
:domain (escape domain) :domain (escape domain)
:summary (when summary (~federation-actor-summary :summary summary)) :summary (when summary (~search/actor-summary :summary summary))
:button (when actor :button (when actor
(if (or (= list-type "following") is-followed) (if (or (= list-type "following") is-followed)
(~federation-unfollow-button (~search/unfollow-button
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url) :action (url-for "social.unfollow") :csrf csrf :actor-url actor-url)
(~federation-follow-button (~search/follow-button
:action (url-for "social.follow") :csrf csrf :actor-url actor-url :action (url-for "social.follow") :csrf csrf :actor-url actor-url
:label (if (= list-type "followers") "Follow Back" "Follow"))))))) :label (if (= list-type "followers") "Follow Back" "Follow")))))))
;; Assembled search content — replaces Python _search_content_sx ;; Assembled search content — replaces Python _search_content_sx
(defcomp ~federation-search-content (&key (query :as string?) (actors :as list) (total :as number) (followed-urls :as list) actor) (defcomp ~search/content (&key (query :as string?) (actors :as list) (total :as number) (followed-urls :as list) actor)
(~federation-search-page (~search/page
:search-url (url-for "social.defpage_search") :search-url (url-for "social.defpage_search")
:search-page-url (url-for "social.search_page") :search-page-url (url-for "social.search_page")
:query (escape (or query "")) :query (escape (or query ""))
:info (cond :info (cond
((and query (> total 0)) ((and query (> total 0))
(~federation-search-info (~search/info
:cls "text-sm text-stone-500 mb-4" :cls "text-sm text-stone-500 mb-4"
:text (str total " result" (pluralize total) " for " (escape query)))) :text (str total " result" (pluralize total) " for " (escape query))))
(query (query
(~federation-search-info (~search/info
:cls "text-stone-500 mb-4" :cls "text-stone-500 mb-4"
:text (str "No results found for " (escape query)))) :text (str "No results found for " (escape query))))
(true nil)) (true nil))
:results (when (not (empty? actors)) :results (when (not (empty? actors))
(<> (<>
(map (lambda (a) (map (lambda (a)
(~federation-actor-card-from-data (~search/actor-card-from-data
:a a :actor actor :followed-urls followed-urls :list-type "search")) :a a :actor actor :followed-urls followed-urls :list-type "search"))
actors) actors)
(when (>= (len actors) 20) (when (>= (len actors) 20)
(~federation-scroll-sentinel (~social/scroll-sentinel
:url (url-for "social.search_page" :q query :page 2))))))) :url (url-for "social.search_page" :q query :page 2)))))))
;; Assembled following/followers content — replaces Python _following_content_sx etc. ;; Assembled following/followers content — replaces Python _following_content_sx etc.
(defcomp ~federation-following-content (&key (actors :as list) (total :as number) actor) (defcomp ~search/following-content (&key (actors :as list) (total :as number) actor)
(~federation-actor-list-page (~search/actor-list-page
:title "Following" :count-str (str "(" total ")") :title "Following" :count-str (str "(" total ")")
:items (when (not (empty? actors)) :items (when (not (empty? actors))
(<> (<>
(map (lambda (a) (map (lambda (a)
(~federation-actor-card-from-data (~search/actor-card-from-data
:a a :actor actor :followed-urls (list) :list-type "following")) :a a :actor actor :followed-urls (list) :list-type "following"))
actors) actors)
(when (>= (len actors) 20) (when (>= (len actors) 20)
(~federation-scroll-sentinel (~social/scroll-sentinel
:url (url-for "social.following_list_page" :page 2))))))) :url (url-for "social.following_list_page" :page 2)))))))
(defcomp ~federation-followers-content (&key (actors :as list) (total :as number) (followed-urls :as list) actor) (defcomp ~search/followers-content (&key (actors :as list) (total :as number) (followed-urls :as list) actor)
(~federation-actor-list-page (~search/actor-list-page
:title "Followers" :count-str (str "(" total ")") :title "Followers" :count-str (str "(" total ")")
:items (when (not (empty? actors)) :items (when (not (empty? actors))
(<> (<>
(map (lambda (a) (map (lambda (a)
(~federation-actor-card-from-data (~search/actor-card-from-data
:a a :actor actor :followed-urls followed-urls :list-type "followers")) :a a :actor actor :followed-urls followed-urls :list-type "followers"))
actors) actors)
(when (>= (len actors) 20) (when (>= (len actors) 20)
(~federation-scroll-sentinel (~social/scroll-sentinel
:url (url-for "social.followers_list_page" :page 2))))))) :url (url-for "social.followers_list_page" :page 2)))))))

View File

@@ -2,46 +2,46 @@
;; --- Navigation --- ;; --- Navigation ---
(defcomp ~federation-nav-choose-username (&key (url :as string)) (defcomp ~social/nav-choose-username (&key (url :as string))
(nav :class "flex gap-3 text-sm items-center" (nav :class "flex gap-3 text-sm items-center"
(a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username"))) (a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username")))
(defcomp ~federation-nav-notification-link (&key (href :as string) (cls :as string) (count-url :as string)) (defcomp ~social/nav-notification-link (&key (href :as string) (cls :as string) (count-url :as string))
(a :href href :class cls "Notifications" (a :href href :class cls "Notifications"
(span :sx-get count-url :sx-trigger "load, every 30s" :sx-swap "innerHTML" (span :sx-get count-url :sx-trigger "load, every 30s" :sx-swap "innerHTML"
:class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"))) :class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden")))
(defcomp ~federation-nav-bar (&key items) (defcomp ~social/nav-bar (&key items)
(nav :class "flex gap-3 text-sm items-center flex-wrap" items)) (nav :class "flex gap-3 text-sm items-center flex-wrap" items))
(defcomp ~federation-social-header (&key nav) (defcomp ~social/header (&key nav)
(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400" (div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"
(div :class "w-full flex flex-row items-center gap-2 flex-wrap" nav))) (div :class "w-full flex flex-row items-center gap-2 flex-wrap" nav)))
;; --- Post card --- ;; --- Post card ---
(defcomp ~federation-boost-label (&key (name :as string)) (defcomp ~social/boost-label (&key (name :as string))
(div :class "text-sm text-stone-500 mb-2" "Boosted by " name)) (div :class "text-sm text-stone-500 mb-2" "Boosted by " name))
;; Aliases — delegate to shared ~avatar ;; Aliases — delegate to shared ~shared:misc/avatar
(defcomp ~federation-avatar-img (&key (src :as string) (cls :as string)) (defcomp ~social/avatar-img (&key (src :as string) (cls :as string))
(~avatar :src src :cls cls)) (~shared:misc/avatar :src src :cls cls))
(defcomp ~federation-avatar-placeholder (&key (cls :as string) (initial :as string)) (defcomp ~social/avatar-placeholder (&key (cls :as string) (initial :as string))
(~avatar :cls cls :initial initial)) (~shared:misc/avatar :cls cls :initial initial))
(defcomp ~federation-content (&key (content :as string) (summary :as string?)) (defcomp ~social/content (&key (content :as string) (summary :as string?))
(if summary (if summary
(details :class "mt-2" (details :class "mt-2"
(summary :class "text-stone-500 cursor-pointer" "CW: " (~rich-text :html summary)) (summary :class "text-stone-500 cursor-pointer" "CW: " (~rich-text :html summary))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content))) (div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content)))
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content)))) (div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content))))
(defcomp ~federation-original-link (&key (url :as string)) (defcomp ~social/original-link (&key (url :as string))
(a :href url :target "_blank" :rel "noopener" (a :href url :target "_blank" :rel "noopener"
:class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original")) :class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original"))
(defcomp ~federation-post-card (&key boost avatar (actor-name :as string) (actor-username :as string) (domain :as string) (time :as string) content original interactions) (defcomp ~social/post-card (&key boost avatar (actor-name :as string) (actor-username :as string) (domain :as string) (time :as string) content original interactions)
(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4" (article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"
boost boost
(div :class "flex items-start gap-3" (div :class "flex items-start gap-3"
@@ -55,36 +55,36 @@
;; --- Interaction buttons --- ;; --- Interaction buttons ---
(defcomp ~federation-reply-link (&key (url :as string)) (defcomp ~social/reply-link (&key (url :as string))
(a :href url :class "hover:text-stone-700" "Reply")) (a :href url :class "hover:text-stone-700" "Reply"))
(defcomp ~federation-like-form (&key (action :as string) (target :as string) (oid :as string) (ainbox :as string) (csrf :as string) (cls :as string) (icon :as string) count) (defcomp ~social/like-form (&key (action :as string) (target :as string) (oid :as string) (ainbox :as string) (csrf :as string) (cls :as string) (icon :as string) count)
(form :sx-post action :sx-target target :sx-swap "innerHTML" (form :sx-post action :sx-target target :sx-swap "innerHTML"
(input :type "hidden" :name "object_id" :value oid) (input :type "hidden" :name "object_id" :value oid)
(input :type "hidden" :name "author_inbox" :value ainbox) (input :type "hidden" :name "author_inbox" :value ainbox)
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class cls (span icon) " " count))) (button :type "submit" :class cls (span icon) " " count)))
(defcomp ~federation-boost-form (&key (action :as string) (target :as string) (oid :as string) (ainbox :as string) (csrf :as string) (cls :as string) count) (defcomp ~social/boost-form (&key (action :as string) (target :as string) (oid :as string) (ainbox :as string) (csrf :as string) (cls :as string) count)
(form :sx-post action :sx-target target :sx-swap "innerHTML" (form :sx-post action :sx-target target :sx-swap "innerHTML"
(input :type "hidden" :name "object_id" :value oid) (input :type "hidden" :name "object_id" :value oid)
(input :type "hidden" :name "author_inbox" :value ainbox) (input :type "hidden" :name "author_inbox" :value ainbox)
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class cls (span "\u21bb") " " count))) (button :type "submit" :class cls (span "\u21bb") " " count)))
(defcomp ~federation-interaction-buttons (&key like boost reply) (defcomp ~social/interaction-buttons (&key like boost reply)
(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500" (div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"
like boost reply)) like boost reply))
;; --- Timeline --- ;; --- Timeline ---
(defcomp ~federation-scroll-sentinel (&key (url :as string)) (defcomp ~social/scroll-sentinel (&key (url :as string))
(div :sx-get url :sx-trigger "revealed" :sx-swap "outerHTML")) (div :sx-get url :sx-trigger "revealed" :sx-swap "outerHTML"))
(defcomp ~federation-compose-button (&key (url :as string)) (defcomp ~social/compose-button (&key (url :as string))
(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose")) (a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose"))
(defcomp ~federation-timeline-page (&key (label :as string) compose timeline) (defcomp ~social/timeline-page (&key (label :as string) compose timeline)
(div :class "flex items-center justify-between mb-6" (div :class "flex items-center justify-between mb-6"
(h1 :class "text-2xl font-bold" label " Timeline") (h1 :class "text-2xl font-bold" label " Timeline")
compose) compose)
@@ -92,24 +92,24 @@
;; --- Data-driven post card (replaces Python _post_card_sx loop) --- ;; --- Data-driven post card (replaces Python _post_card_sx loop) ---
(defcomp ~federation-post-card-from-data (&key (d :as dict) (has-actor :as boolean) (csrf :as string) (defcomp ~social/post-card-from-data (&key (d :as dict) (has-actor :as boolean) (csrf :as string)
(like-url :as string) (unlike-url :as string) (like-url :as string) (unlike-url :as string)
(boost-url :as string) (unboost-url :as string)) (boost-url :as string) (unboost-url :as string))
(let* ((boosted-by (get d "boosted_by")) (let* ((boosted-by (get d "boosted_by"))
(actor-icon (get d "actor_icon")) (actor-icon (get d "actor_icon"))
(actor-name (get d "actor_name")) (actor-name (get d "actor_name"))
(initial (or (get d "initial") "?")) (initial (or (get d "initial") "?"))
(avatar (~avatar (avatar (~shared:misc/avatar
:src actor-icon :src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full" :cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm") "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
:initial (when (not actor-icon) initial))) :initial (when (not actor-icon) initial)))
(boost (when boosted-by (~federation-boost-label :name boosted-by))) (boost (when boosted-by (~social/boost-label :name boosted-by)))
(content-sx (if (get d "summary") (content-sx (if (get d "summary")
(~federation-content :content (get d "content") :summary (get d "summary")) (~social/content :content (get d "content") :summary (get d "summary"))
(~federation-content :content (get d "content")))) (~social/content :content (get d "content"))))
(original (when (get d "original_url") (original (when (get d "original_url")
(~federation-original-link :url (get d "original_url")))) (~social/original-link :url (get d "original_url"))))
(safe-id (get d "safe_id")) (safe-id (get d "safe_id"))
(interactions (when has-actor (interactions (when has-actor
(let* ((oid (get d "object_id")) (let* ((oid (get d "object_id"))
@@ -123,16 +123,16 @@
(b-action (if boosted-me unboost-url boost-url)) (b-action (if boosted-me unboost-url boost-url))
(b-cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600"))) (b-cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600")))
(reply-url (get d "reply_url")) (reply-url (get d "reply_url"))
(reply (when reply-url (~federation-reply-link :url reply-url))) (reply (when reply-url (~social/reply-link :url reply-url)))
(like-form (~federation-like-form (like-form (~social/like-form
:action l-action :target target :oid oid :ainbox ainbox :action l-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls l-cls :icon l-icon :count (get d "like_count"))) :csrf csrf :cls l-cls :icon l-icon :count (get d "like_count")))
(boost-form (~federation-boost-form (boost-form (~social/boost-form
:action b-action :target target :oid oid :ainbox ainbox :action b-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls b-cls :count (get d "boost_count")))) :csrf csrf :cls b-cls :count (get d "boost_count"))))
(div :id (str "interactions-" safe-id) (div :id (str "interactions-" safe-id)
(~federation-interaction-buttons :like like-form :boost boost-form :reply reply)))))) (~social/interaction-buttons :like like-form :boost boost-form :reply reply))))))
(~federation-post-card (~social/post-card
:boost boost :avatar avatar :boost boost :avatar avatar
:actor-name actor-name :actor-username (get d "actor_username") :actor-name actor-name :actor-username (get d "actor_username")
:domain (get d "domain") :time (get d "time") :domain (get d "domain") :time (get d "time")
@@ -140,22 +140,22 @@
:interactions interactions))) :interactions interactions)))
;; Data-driven timeline items (replaces Python _timeline_items_sx loop) ;; Data-driven timeline items (replaces Python _timeline_items_sx loop)
(defcomp ~federation-timeline-items-from-data (&key (items :as list) (next-url :as string?) (has-actor :as boolean) (csrf :as string) (defcomp ~social/timeline-items-from-data (&key (items :as list) (next-url :as string?) (has-actor :as boolean) (csrf :as string)
(like-url :as string) (unlike-url :as string) (boost-url :as string) (unboost-url :as string)) (like-url :as string) (unlike-url :as string) (boost-url :as string) (unboost-url :as string))
(<> (<>
(map (lambda (d) (map (lambda (d)
(~federation-post-card-from-data :d d :has-actor has-actor :csrf csrf (~social/post-card-from-data :d d :has-actor has-actor :csrf csrf
:like-url like-url :unlike-url unlike-url :boost-url boost-url :unboost-url unboost-url)) :like-url like-url :unlike-url unlike-url :boost-url boost-url :unboost-url unboost-url))
(or items (list))) (or items (list)))
(when next-url (~federation-scroll-sentinel :url next-url)))) (when next-url (~social/scroll-sentinel :url next-url))))
;; --- Compose --- ;; --- Compose ---
(defcomp ~federation-compose-reply (&key (reply-to :as string)) (defcomp ~social/compose-reply (&key (reply-to :as string))
(input :type "hidden" :name "in_reply_to" :value reply-to) (input :type "hidden" :name "in_reply_to" :value reply-to)
(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" reply-to))) (div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" reply-to)))
(defcomp ~federation-compose-form (&key (action :as string) (csrf :as string) reply) (defcomp ~social/compose-form (&key (action :as string) (csrf :as string) reply)
(h1 :class "text-2xl font-bold mb-6" "Compose") (h1 :class "text-2xl font-bold mb-6" "Compose")
(form :method "post" :action action :class "space-y-4" (form :method "post" :action action :class "space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
@@ -174,9 +174,9 @@
;; Assembled social nav — replaces Python _social_nav_sx ;; Assembled social nav — replaces Python _social_nav_sx
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~federation-social-nav (&key actor) (defcomp ~social/nav (&key actor)
(if (not actor) (if (not actor)
(~federation-nav-choose-username :url (url-for "identity.choose_username_form")) (~social/nav-choose-username :url (url-for "identity.choose_username_form"))
(let* ((rp (request-path)) (let* ((rp (request-path))
(links (list (links (list
(dict :endpoint "social.defpage_home_timeline" :label "Timeline") (dict :endpoint "social.defpage_home_timeline" :label "Timeline")
@@ -185,7 +185,7 @@
(dict :endpoint "social.defpage_following_list" :label "Following") (dict :endpoint "social.defpage_following_list" :label "Following")
(dict :endpoint "social.defpage_followers_list" :label "Followers") (dict :endpoint "social.defpage_followers_list" :label "Followers")
(dict :endpoint "social.defpage_search" :label "Search")))) (dict :endpoint "social.defpage_search" :label "Search"))))
(~federation-nav-bar (~social/nav-bar
:items (<> :items (<>
(map (lambda (lnk) (map (lambda (lnk)
(let* ((href (url-for (get lnk "endpoint"))) (let* ((href (url-for (get lnk "endpoint")))
@@ -196,7 +196,7 @@
links) links)
(let* ((notif-url (url-for "social.defpage_notifications")) (let* ((notif-url (url-for "social.defpage_notifications"))
(notif-bold (if (= rp notif-url) " font-bold" ""))) (notif-bold (if (= rp notif-url) " font-bold" "")))
(~federation-nav-notification-link (~social/nav-notification-link
:href notif-url :href notif-url
:cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold) :cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold)
:count-url (url-for "social.notification_count"))) :count-url (url-for "social.notification_count")))
@@ -208,7 +208,7 @@
;; Assembled post card — replaces Python _post_card_sx ;; Assembled post card — replaces Python _post_card_sx
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~federation-post-card-from-data (&key (item :as dict) actor) (defcomp ~social/post-card-from-data (&key (item :as dict) actor)
(let* ((boosted-by (get item "boosted_by")) (let* ((boosted-by (get item "boosted_by"))
(actor-icon (get item "actor_icon")) (actor-icon (get item "actor_icon"))
(actor-name (or (get item "actor_name") "?")) (actor-name (or (get item "actor_name") "?"))
@@ -223,9 +223,9 @@
(safe-id (replace (replace oid "/" "_") ":" "_")) (safe-id (replace (replace oid "/" "_") ":" "_"))
(initial (if (and (not actor-icon) actor-name) (initial (if (and (not actor-icon) actor-name)
(upper (slice actor-name 0 1)) "?"))) (upper (slice actor-name 0 1)) "?")))
(~federation-post-card (~social/post-card
:boost (when boosted-by (~federation-boost-label :name (escape boosted-by))) :boost (when boosted-by (~social/boost-label :name (escape boosted-by)))
:avatar (~avatar :avatar (~shared:misc/avatar
:src actor-icon :src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full" :cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm") "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
@@ -235,10 +235,10 @@
:domain (if actor-domain (str "@" (escape actor-domain)) "") :domain (if actor-domain (str "@" (escape actor-domain)) "")
:time published :time published
:content (if summary :content (if summary
(~federation-content :content content :summary (escape summary)) (~social/content :content content :summary (escape summary))
(~federation-content :content content)) (~social/content :content content))
:original (when (and url (= post-type "remote")) :original (when (and url (= post-type "remote"))
(~federation-original-link :url url)) (~social/original-link :url url))
:interactions (when actor :interactions (when actor
(let* ((csrf (csrf-token)) (let* ((csrf (csrf-token))
(liked (get item "liked_by_me")) (liked (get item "liked_by_me"))
@@ -248,50 +248,50 @@
(ainbox (or (get item "author_inbox") "")) (ainbox (or (get item "author_inbox") ""))
(target (str "#interactions-" safe-id))) (target (str "#interactions-" safe-id)))
(div :id (str "interactions-" safe-id) (div :id (str "interactions-" safe-id)
(~federation-interaction-buttons (~social/interaction-buttons
:like (~federation-like-form :like (~social/like-form
:action (url-for (if liked "social.unlike" "social.like")) :action (url-for (if liked "social.unlike" "social.like"))
:target target :oid oid :ainbox ainbox :csrf csrf :target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500")) :cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500"))
:icon (if liked "\u2665" "\u2661") :count (str lcount)) :icon (if liked "\u2665" "\u2661") :count (str lcount))
:boost (~federation-boost-form :boost (~social/boost-form
:action (url-for (if boosted-me "social.unboost" "social.boost")) :action (url-for (if boosted-me "social.unboost" "social.boost"))
:target target :oid oid :ainbox ainbox :csrf csrf :target target :oid oid :ainbox ainbox :csrf csrf
:cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600")) :cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600"))
:count (str bcount)) :count (str bcount))
:reply (when oid :reply (when oid
(~federation-reply-link (~social/reply-link
:url (url-for "social.defpage_compose_form" :reply-to oid)))))))))) :url (url-for "social.defpage_compose_form" :reply-to oid))))))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Assembled timeline items — replaces Python _timeline_items_sx ;; Assembled timeline items — replaces Python _timeline_items_sx
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~federation-timeline-items (&key (items :as list) (timeline-type :as string) actor (next-url :as string?)) (defcomp ~social/timeline-items (&key (items :as list) (timeline-type :as string) actor (next-url :as string?))
(<> (<>
(map (lambda (item) (map (lambda (item)
(~federation-post-card-from-data :item item :actor actor)) (~social/post-card-from-data :item item :actor actor))
items) items)
(when next-url (when next-url
(~federation-scroll-sentinel :url next-url)))) (~social/scroll-sentinel :url next-url))))
;; Assembled timeline content — replaces Python _timeline_content_sx ;; Assembled timeline content — replaces Python _timeline_content_sx
(defcomp ~federation-timeline-content (&key (items :as list) (timeline-type :as string) actor) (defcomp ~social/timeline-content (&key (items :as list) (timeline-type :as string) actor)
(let* ((label (if (= timeline-type "home") "Home" "Public"))) (let* ((label (if (= timeline-type "home") "Home" "Public")))
(~federation-timeline-page (~social/timeline-page
:label label :label label
:compose (when actor :compose (when actor
(~federation-compose-button :url (url-for "social.defpage_compose_form"))) (~social/compose-button :url (url-for "social.defpage_compose_form")))
:timeline (~federation-timeline-items :timeline (~social/timeline-items
:items items :timeline-type timeline-type :actor actor :items items :timeline-type timeline-type :actor actor
:next-url (when (not (empty? items)) :next-url (when (not (empty? items))
(url-for (str "social." timeline-type "_timeline_page") (url-for (str "social." timeline-type "_timeline_page")
:before (get (last items) "before_cursor"))))))) :before (get (last items) "before_cursor")))))))
;; Assembled compose content — replaces Python _compose_content_sx ;; Assembled compose content — replaces Python _compose_content_sx
(defcomp ~federation-compose-content (&key (reply-to :as string?)) (defcomp ~social/compose-content (&key (reply-to :as string?))
(~federation-compose-form (~social/compose-form
:action (url-for "social.compose_submit") :action (url-for "social.compose_submit")
:csrf (csrf-token) :csrf (csrf-token)
:reply (when reply-to :reply (when reply-to
(~federation-compose-reply :reply-to (escape reply-to))))) (~social/compose-reply :reply-to (escape reply-to)))))

View File

@@ -6,7 +6,7 @@
:auth :login :auth :login
:layout :social :layout :social
:data (service "federation-page" "home-timeline-data") :data (service "federation-page" "home-timeline-data")
:content (~federation-timeline-content :content (~social/timeline-content
:items items :items items
:timeline-type timeline-type :timeline-type timeline-type
:actor actor)) :actor actor))
@@ -16,7 +16,7 @@
:auth :public :auth :public
:layout :social :layout :social
:data (service "federation-page" "public-timeline-data") :data (service "federation-page" "public-timeline-data")
:content (~federation-timeline-content :content (~social/timeline-content
:items items :items items
:timeline-type timeline-type :timeline-type timeline-type
:actor actor)) :actor actor))
@@ -26,7 +26,7 @@
:auth :login :auth :login
:layout :social :layout :social
:data (service "federation-page" "compose-data") :data (service "federation-page" "compose-data")
:content (~federation-compose-content :content (~social/compose-content
:reply-to reply-to)) :reply-to reply-to))
(defpage search (defpage search
@@ -34,7 +34,7 @@
:auth :public :auth :public
:layout :social :layout :social
:data (service "federation-page" "search-data") :data (service "federation-page" "search-data")
:content (~federation-search-content :content (~search/content
:query query :query query
:actors actors :actors actors
:total total :total total
@@ -46,7 +46,7 @@
:auth :login :auth :login
:layout :social :layout :social
:data (service "federation-page" "following-data") :data (service "federation-page" "following-data")
:content (~federation-following-content :content (~search/following-content
:actors actors :actors actors
:total total :total total
:actor actor)) :actor actor))
@@ -56,7 +56,7 @@
:auth :login :auth :login
:layout :social :layout :social
:data (service "federation-page" "followers-data") :data (service "federation-page" "followers-data")
:content (~federation-followers-content :content (~search/followers-content
:actors actors :actors actors
:total total :total total
:followed-urls followed-urls :followed-urls followed-urls
@@ -67,7 +67,7 @@
:auth :public :auth :public
:layout :social :layout :social
:data (service "federation-page" "actor-timeline-data" :id id) :data (service "federation-page" "actor-timeline-data" :id id)
:content (~federation-actor-timeline-content :content (~profile/actor-timeline-content
:remote-actor remote-actor :remote-actor remote-actor
:items items :items items
:is-following is-following :is-following is-following
@@ -78,5 +78,5 @@
:auth :login :auth :login
:layout :social :layout :social
:data (service "federation-page" "notifications-data") :data (service "federation-page" "notifications-data")
:content (~federation-notifications-content :content (~notifications/content
:notifications notifications)) :notifications notifications))

View File

@@ -27,7 +27,7 @@ async def _social_page(ctx: dict, actor, *, content: str,
from markupsafe import escape from markupsafe import escape
env = {"actor": _serialize_actor(actor) if actor else None} env = {"actor": _serialize_actor(actor) if actor else None}
header_rows = await render_to_sx_with_env("social-layout-full", env) header_rows = await render_to_sx_with_env("layouts/social-layout-full", env)
return await full_page_sx(ctx, header_rows=header_rows, content=content, return await full_page_sx(ctx, header_rows=header_rows, content=content,
meta_html=meta_html or f'<title>{escape(title)}</title>') meta_html=meta_html or f'<title>{escape(title)}</title>')

View File

@@ -1,40 +1,40 @@
;; Market card components — pure data, no raw! HTML injection ;; Market card components — pure data, no raw! HTML injection
(defcomp ~market-label-overlay (&key (src :as string)) (defcomp ~cards/label-overlay (&key (src :as string))
(img :src src :alt "" (img :src src :alt ""
:class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top")) :class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top"))
(defcomp ~market-card-image (&key (image :as string) (labels :as list?) (brand :as string) (brand-highlight :as string?)) (defcomp ~cards/image (&key (image :as string) (labels :as list?) (brand :as string) (brand-highlight :as string?))
(div :class "w-full aspect-square bg-stone-100 relative" (div :class "w-full aspect-square bg-stone-100 relative"
(figure :class "inline-block w-full h-full" (figure :class "inline-block w-full h-full"
(div :class "relative w-full h-full" (div :class "relative w-full h-full"
(img :src image :alt "no image" :class "absolute inset-0 w-full h-full object-contain object-top" :loading "lazy" :decoding "async" :fetchpriority "low") (img :src image :alt "no image" :class "absolute inset-0 w-full h-full object-contain object-top" :loading "lazy" :decoding "async" :fetchpriority "low")
(when labels (map (lambda (src) (~market-label-overlay :src src)) labels))) (when labels (map (lambda (src) (~cards/label-overlay :src src)) labels)))
(figcaption :class (str "mt-2 text-sm text-center" brand-highlight " text-stone-600") brand)))) (figcaption :class (str "mt-2 text-sm text-center" brand-highlight " text-stone-600") brand))))
(defcomp ~market-card-no-image (&key (labels :as list?) (brand :as string)) (defcomp ~cards/no-image (&key (labels :as list?) (brand :as string))
(div :class "w-full aspect-square bg-stone-100 relative" (div :class "w-full aspect-square bg-stone-100 relative"
(div :class "p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative" (div :class "p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative"
(div :class "text-stone-400 text-xs" "No image") (div :class "text-stone-400 text-xs" "No image")
(when labels (ul :class "flex flex-row gap-1" (map (lambda (l) (li l)) labels))) (when labels (ul :class "flex flex-row gap-1" (map (lambda (l) (li l)) labels)))
(div :class "text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" brand)))) (div :class "text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" brand))))
(defcomp ~market-card-sticker (&key (src :as string) (name :as string) (ring-cls :as string?)) (defcomp ~cards/sticker (&key (src :as string) (name :as string) (ring-cls :as string?))
(img :src src :alt name :class (str "w-6 h-6" ring-cls))) (img :src src :alt name :class (str "w-6 h-6" ring-cls)))
(defcomp ~market-card-stickers (&key (stickers :as list)) (defcomp ~cards/stickers (&key (stickers :as list))
(div :class "flex flex-row justify-center gap-2 p-2" (div :class "flex flex-row justify-center gap-2 p-2"
(map (lambda (s) (~market-card-sticker :src (get s "src") :name (get s "name") :ring-cls (get s "ring-cls"))) stickers))) (map (lambda (s) (~cards/sticker :src (get s "src") :name (get s "name") :ring-cls (get s "ring-cls"))) stickers)))
(defcomp ~market-card-highlight (&key (pre :as string) (mid :as string) (post :as string)) (defcomp ~cards/highlight (&key (pre :as string) (mid :as string) (post :as string))
(<> pre (mark mid) post)) (<> pre (mark mid) post))
;; Price — delegates to shared ~price ;; Price — delegates to shared ~shared:misc/price
(defcomp ~market-card-price (&key (special-price :as string?) (regular-price :as string?)) (defcomp ~cards/price (&key (special-price :as string?) (regular-price :as string?))
(~price :special-price special-price :regular-price regular-price)) (~shared:misc/price :special-price special-price :regular-price regular-price))
;; Main product card — accepts pure data, composes sub-components ;; Main product card — accepts pure data, composes sub-components
(defcomp ~market-product-card (&key (href :as string) (hx-select :as string) (defcomp ~cards/product-card (&key (href :as string) (hx-select :as string)
(has-like :as boolean) (liked :as boolean?) (slug :as string) (csrf :as string) (like-action :as string?) (has-like :as boolean) (liked :as boolean?) (slug :as string) (csrf :as string) (like-action :as string?)
(image :as string?) (labels :as list?) (brand :as string) (brand-highlight :as string?) (image :as string?) (labels :as list?) (brand :as string) (brand-highlight :as string?)
(special-price :as string?) (regular-price :as string?) (special-price :as string?) (regular-price :as string?)
@@ -43,29 +43,29 @@
(title :as string) (has-highlight :as boolean) (search-pre :as string?) (search-mid :as string?) (search-post :as string?)) (title :as string) (has-highlight :as boolean) (search-pre :as string?) (search-mid :as string?) (search-post :as string?))
(div :class "flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative" (div :class "flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative"
(when has-like (when has-like
(~market-like-button :form-id (str "like-" slug) :action like-action :slug slug :csrf csrf (~cards/like-button :form-id (str "like-" slug) :action like-action :slug slug :csrf csrf
:icon-cls (if liked "fa-solid fa-heart text-red-500" "fa-regular fa-heart text-stone-400"))) :icon-cls (if liked "fa-solid fa-heart text-red-500" "fa-regular fa-heart text-stone-400")))
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
(if image (if image
(~market-card-image :image image :labels labels :brand brand :brand-highlight brand-highlight) (~cards/image :image image :labels labels :brand brand :brand-highlight brand-highlight)
(~market-card-no-image :labels labels :brand brand)) (~cards/no-image :labels labels :brand brand))
(~market-card-price :special-price special-price :regular-price regular-price)) (~cards/price :special-price special-price :regular-price regular-price))
(div :class "flex justify-center" (div :class "flex justify-center"
(if quantity (if quantity
(~market-cart-add-quantity :cart-id (str "cart-" slug) :action cart-action :csrf csrf (~cart/add-quantity :cart-id (str "cart-" slug) :action cart-action :csrf csrf
:minus-val (str (- quantity 1)) :plus-val (str (+ quantity 1)) :minus-val (str (- quantity 1)) :plus-val (str (+ quantity 1))
:quantity (str quantity) :cart-href cart-href) :quantity (str quantity) :cart-href cart-href)
(~market-cart-add-empty :cart-id (str "cart-" slug) :action cart-action :csrf csrf))) (~cart/add-empty :cart-id (str "cart-" slug) :action cart-action :csrf csrf)))
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
(when stickers (~market-card-stickers :stickers stickers)) (when stickers (~cards/stickers :stickers stickers))
(div :class "text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" (div :class "text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]"
(if has-highlight (if has-highlight
(~market-card-highlight :pre search-pre :mid search-mid :post search-post) (~cards/highlight :pre search-pre :mid search-mid :post search-post)
title))))) title)))))
(defcomp ~market-like-button (&key (form-id :as string) (action :as string) (slug :as string) (csrf :as string) (icon-cls :as string)) (defcomp ~cards/like-button (&key (form-id :as string) (action :as string) (slug :as string) (csrf :as string) (icon-cls :as string))
(div :class "absolute top-2 right-2 z-10 text-6xl md:text-xl" (div :class "absolute top-2 right-2 z-10 text-6xl md:text-xl"
(form :id form-id :action action :method "post" (form :id form-id :action action :method "post"
:sx-post action :sx-target (str "#like-" slug) :sx-swap "outerHTML" :sx-post action :sx-target (str "#like-" slug) :sx-swap "outerHTML"
@@ -73,22 +73,22 @@
(button :type "submit" :class "cursor-pointer" (button :type "submit" :class "cursor-pointer"
(i :class icon-cls :aria-hidden "true"))))) (i :class icon-cls :aria-hidden "true")))))
(defcomp ~market-market-card-title-link (&key (href :as string) (name :as string)) (defcomp ~cards/market-card-title-link (&key (href :as string) (name :as string))
(a :href href :class "hover:text-emerald-700" (a :href href :class "hover:text-emerald-700"
(h2 :class "text-lg font-semibold text-stone-900" name))) (h2 :class "text-lg font-semibold text-stone-900" name)))
(defcomp ~market-market-card-title (&key (name :as string)) (defcomp ~cards/market-card-title (&key (name :as string))
(h2 :class "text-lg font-semibold text-stone-900" name)) (h2 :class "text-lg font-semibold text-stone-900" name))
(defcomp ~market-market-card-desc (&key (description :as string)) (defcomp ~cards/market-card-desc (&key (description :as string))
(p :class "text-sm text-stone-600 mt-1 line-clamp-2" description)) (p :class "text-sm text-stone-600 mt-1 line-clamp-2" description))
(defcomp ~market-market-card-badge (&key (href :as string) (title :as string)) (defcomp ~cards/market-card-badge (&key (href :as string) (title :as string))
(div :class "flex flex-wrap items-center gap-1.5 mt-3" (div :class "flex flex-wrap items-center gap-1.5 mt-3"
(a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" (a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200"
title))) title)))
(defcomp ~market-market-card (&key (title-content :as list?) (desc-content :as list?) (badge-content :as list?) (title :as string?) (desc :as string?) (badge :as string?)) (defcomp ~cards/market-card (&key (title-content :as list?) (desc-content :as list?) (badge-content :as list?) (title :as string?) (desc :as string?) (badge :as string?))
(article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors" (article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors"
(div (div
(if title-content title-content (when title title)) (if title-content title-content (when title title))
@@ -101,11 +101,11 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Product cards grid with infinite scroll sentinels ;; Product cards grid with infinite scroll sentinels
(defcomp ~market-product-cards-content (&key (products :as list) (page :as number) (total-pages :as number) (next-url :as string) (defcomp ~cards/product-cards-content (&key (products :as list) (page :as number) (total-pages :as number) (next-url :as string)
(mobile-sentinel-hs :as string?) (desktop-sentinel-hs :as string?)) (mobile-sentinel-hs :as string?) (desktop-sentinel-hs :as string?))
(<> (<>
(map (lambda (p) (map (lambda (p)
(~market-product-card (~cards/product-card
:href (get p "href") :hx-select (get p "hx-select") :slug (get p "slug") :href (get p "href") :hx-select (get p "hx-select") :slug (get p "slug")
:image (get p "image") :brand (get p "brand") :brand-highlight (get p "brand-highlight") :image (get p "image") :brand (get p "brand") :brand-highlight (get p "brand-highlight")
:special-price (get p "special-price") :regular-price (get p "regular-price") :special-price (get p "special-price") :regular-price (get p "regular-price")
@@ -119,39 +119,39 @@
:search-post (get p "search-post"))) :search-post (get p "search-post")))
products) products)
(if (< page total-pages) (if (< page total-pages)
(<> (~sentinel-mobile :id (str "sentinel-" page "-m") :next-url next-url (<> (~shared:misc/sentinel-mobile :id (str "sentinel-" page "-m") :next-url next-url
:hyperscript mobile-sentinel-hs) :hyperscript mobile-sentinel-hs)
(~sentinel-desktop :id (str "sentinel-" page "-d") :next-url next-url (~shared:misc/sentinel-desktop :id (str "sentinel-" page "-d") :next-url next-url
:hyperscript desktop-sentinel-hs)) :hyperscript desktop-sentinel-hs))
(~end-of-results)))) (~shared:misc/end-of-results))))
;; Single market card from data (handles conditional title/desc/badge) ;; Single market card from data (handles conditional title/desc/badge)
(defcomp ~market-card-from-data (&key (name :as string) (description :as string?) (href :as string?) (show-badge :as boolean) (badge-href :as string?) (badge-title :as string?)) (defcomp ~cards/from-data (&key (name :as string) (description :as string?) (href :as string?) (show-badge :as boolean) (badge-href :as string?) (badge-title :as string?))
(~market-market-card (~cards/market-card
:title-content (if href :title-content (if href
(~market-market-card-title-link :href href :name name) (~cards/market-card-title-link :href href :name name)
(~market-market-card-title :name name)) (~cards/market-card-title :name name))
:desc-content (when description :desc-content (when description
(~market-market-card-desc :description description)) (~cards/market-card-desc :description description))
:badge-content (when (and show-badge badge-title) :badge-content (when (and show-badge badge-title)
(~market-market-card-badge :href badge-href :title badge-title)))) (~cards/market-card-badge :href badge-href :title badge-title))))
;; Market cards list with infinite scroll sentinel ;; Market cards list with infinite scroll sentinel
(defcomp ~market-cards-content (&key (markets :as list) (page :as number) (has-more :as boolean) (next-url :as string)) (defcomp ~cards/content (&key (markets :as list) (page :as number) (has-more :as boolean) (next-url :as string))
(<> (<>
(map (lambda (m) (map (lambda (m)
(~market-card-from-data (~cards/from-data
:name (get m "name") :description (get m "description") :name (get m "name") :description (get m "description")
:href (get m "href") :show-badge (get m "show-badge") :href (get m "href") :show-badge (get m "show-badge")
:badge-href (get m "badge-href") :badge-title (get m "badge-title"))) :badge-href (get m "badge-href") :badge-title (get m "badge-title")))
markets) markets)
(when has-more (when has-more
(~sentinel-simple :id (str "sentinel-" page) :next-url next-url)))) (~shared:misc/sentinel-simple :id (str "sentinel-" page) :next-url next-url))))
;; Market landing page content from data ;; Market landing page content from data
(defcomp ~market-landing-from-data (&key (excerpt :as string?) (feature-image :as string?) (html :as string?)) (defcomp ~cards/landing-from-data (&key (excerpt :as string?) (feature-image :as string?) (html :as string?))
(~market-landing-content :inner (~detail/landing-content :inner
(<> (when excerpt (~market-landing-excerpt :text excerpt)) (<> (when excerpt (~detail/landing-excerpt :text excerpt))
(when feature-image (~market-landing-image :src feature-image)) (when feature-image (~detail/landing-image :src feature-image))
(when html (~market-landing-html :html html))))) (when html (~detail/landing-html :html html)))))

View File

@@ -1,6 +1,6 @@
;; Market cart components ;; Market cart components
(defcomp ~market-cart-add-empty (&key cart-id action csrf) (defcomp ~cart/add-empty (&key cart-id action csrf)
(div :id cart-id (div :id cart-id
(form :action action :method "post" :sx-post action :sx-target "#cart-mini" :sx-swap "outerHTML" :class "rounded flex items-center" (form :action action :method "post" :sx-post action :sx-target "#cart-mini" :sx-swap "outerHTML" :class "rounded flex items-center"
(input :type "hidden" :name "csrf_token" :value csrf) (input :type "hidden" :name "csrf_token" :value csrf)
@@ -9,7 +9,7 @@
(span :class "relative inline-flex items-center justify-center" (span :class "relative inline-flex items-center justify-center"
(i :class "fa fa-cart-plus text-4xl" :aria-hidden "true")))))) (i :class "fa fa-cart-plus text-4xl" :aria-hidden "true"))))))
(defcomp ~market-cart-add-quantity (&key cart-id action csrf minus-val plus-val quantity cart-href) (defcomp ~cart/add-quantity (&key cart-id action csrf minus-val plus-val quantity cart-href)
(div :id cart-id (div :id cart-id
(div :class "rounded flex items-center gap-2" (div :class "rounded flex items-center gap-2"
(form :action action :method "post" :sx-post action :sx-target "#cart-mini" :sx-swap "outerHTML" (form :action action :method "post" :sx-post action :sx-target "#cart-mini" :sx-swap "outerHTML"
@@ -26,7 +26,7 @@
(input :type "hidden" :name "count" :value plus-val) (input :type "hidden" :name "count" :value plus-val)
(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+"))))) (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))))
(defcomp ~market-cart-mini-count (&key href count) (defcomp ~cart/mini-count (&key href count)
(div :id "cart-mini" :sx-swap-oob "outerHTML" (div :id "cart-mini" :sx-swap-oob "outerHTML"
(a :href href :class "relative inline-flex items-center justify-center" (a :href href :class "relative inline-flex items-center justify-center"
(span :class "relative inline-flex items-center justify-center" (span :class "relative inline-flex items-center justify-center"
@@ -35,25 +35,25 @@
(span :class "flex items-center justify-center bg-emerald-500 text-white rounded-full min-w-[1.25rem] h-5 text-xs font-bold px-1" (span :class "flex items-center justify-center bg-emerald-500 text-white rounded-full min-w-[1.25rem] h-5 text-xs font-bold px-1"
count)))))) count))))))
(defcomp ~market-cart-mini-empty (&key href logo) (defcomp ~cart/mini-empty (&key href logo)
(div :id "cart-mini" :sx-swap-oob "outerHTML" (div :id "cart-mini" :sx-swap-oob "outerHTML"
(a :href href :class "relative inline-flex items-center justify-center" (a :href href :class "relative inline-flex items-center justify-center"
(img :src logo :class "h-8 w-8 rounded-full object-cover border border-stone-300" :alt "")))) (img :src logo :class "h-8 w-8 rounded-full object-cover border border-stone-300" :alt ""))))
(defcomp ~market-cart-add-oob (&key id content inner) (defcomp ~cart/add-oob (&key id content inner)
(div :id id :sx-swap-oob "outerHTML" (div :id id :sx-swap-oob "outerHTML"
(if content content (when inner inner)))) (if content content (when inner inner))))
;; Cart added response — composes cart mini + add/remove OOB in sx ;; Cart added response — composes cart mini + add/remove OOB in sx
(defcomp ~market-cart-added-response (&key has-count cart-href blog-href logo (defcomp ~cart/added-response (&key has-count cart-href blog-href logo
slug action csrf quantity minus-val plus-val) slug action csrf quantity minus-val plus-val)
(<> (<>
(if has-count (if has-count
(~market-cart-mini-count :href cart-href :count (str has-count)) (~cart/mini-count :href cart-href :count (str has-count))
(~market-cart-mini-empty :href blog-href :logo logo)) (~cart/mini-empty :href blog-href :logo logo))
(~market-cart-add-oob :id (str "cart-add-" slug) (~cart/add-oob :id (str "cart-add-" slug)
:inner (if (= (or quantity "0") "0") :inner (if (= (or quantity "0") "0")
(~market-cart-add-empty :cart-id (str "cart-" slug) :action action :csrf csrf) (~cart/add-empty :cart-id (str "cart-" slug) :action action :csrf csrf)
(~market-cart-add-quantity :cart-id (str "cart-" slug) :action action :csrf csrf (~cart/add-quantity :cart-id (str "cart-" slug) :action action :csrf csrf
:minus-val minus-val :plus-val plus-val :minus-val minus-val :plus-val plus-val
:quantity quantity :cart-href cart-href))))) :quantity quantity :cart-href cart-href)))))

View File

@@ -1,6 +1,6 @@
;; Market product detail components ;; Market product detail components
(defcomp ~market-detail-gallery-inner (&key (like :as list?) (image :as string) (alt :as string) (labels :as list?) (brand :as string)) (defcomp ~detail/gallery-inner (&key (like :as list?) (image :as string) (alt :as string) (labels :as list?) (brand :as string))
(<> like (<> like
(figure :class "inline-block" (figure :class "inline-block"
(div :class "relative w-full aspect-square" (div :class "relative w-full aspect-square"
@@ -9,7 +9,7 @@
labels) labels)
(figcaption :class "mt-2 text-sm text-stone-600 text-center" brand)))) (figcaption :class "mt-2 text-sm text-stone-600 text-center" brand))))
(defcomp ~market-detail-nav-buttons () (defcomp ~detail/nav-buttons ()
(<> (<>
(button :type "button" :data-prev "" (button :type "button" :data-prev ""
:class "absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl" :class "absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl"
@@ -18,79 +18,79 @@
:class "absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl" :class "absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl"
:title "Next" "\u203a"))) :title "Next" "\u203a")))
(defcomp ~market-detail-gallery (&key (inner :as list) (nav :as list?)) (defcomp ~detail/gallery (&key (inner :as list) (nav :as list?))
(div :class "relative rounded-xl overflow-hidden bg-stone-100" (div :class "relative rounded-xl overflow-hidden bg-stone-100"
inner nav)) inner nav))
(defcomp ~market-detail-thumb (&key (title :as string) (src :as string) (alt :as string)) (defcomp ~detail/thumb (&key (title :as string) (src :as string) (alt :as string))
(<> (button :type "button" :data-thumb "" (<> (button :type "button" :data-thumb ""
:class "shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2" :class "shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"
:title title :title title
(img :src src :class "h-16 w-16 object-contain" :alt alt :loading "lazy" :decoding "async")) (img :src src :class "h-16 w-16 object-contain" :alt alt :loading "lazy" :decoding "async"))
(span :data-image-src src :class "hidden"))) (span :data-image-src src :class "hidden")))
(defcomp ~market-detail-thumbs (&key (thumbs :as list)) (defcomp ~detail/thumbs (&key (thumbs :as list))
(div :class "flex flex-row justify-center" (div :class "flex flex-row justify-center"
(div :class "mt-3 flex gap-2 overflow-x-auto no-scrollbar" thumbs))) (div :class "mt-3 flex gap-2 overflow-x-auto no-scrollbar" thumbs)))
(defcomp ~market-detail-no-image (&key (like :as list?)) (defcomp ~detail/no-image (&key (like :as list?))
(div :class "relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400" (div :class "relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400"
like "No image")) like "No image"))
(defcomp ~market-detail-sticker (&key (src :as string) (name :as string)) (defcomp ~detail/sticker (&key (src :as string) (name :as string))
(img :src src :alt name :class "w-10 h-10")) (img :src src :alt name :class "w-10 h-10"))
(defcomp ~market-detail-stickers (&key (items :as list)) (defcomp ~detail/stickers (&key (items :as list))
(div :class "p-2 flex flex-row justify-center gap-2" items)) (div :class "p-2 flex flex-row justify-center gap-2" items))
(defcomp ~market-detail-unit-price (&key (price :as string)) (defcomp ~detail/unit-price (&key (price :as string))
(div (str "Unit price: " price))) (div (str "Unit price: " price)))
(defcomp ~market-detail-case-size (&key (size :as string)) (defcomp ~detail/case-size (&key (size :as string))
(div (str "Case size: " size))) (div (str "Case size: " size)))
(defcomp ~market-detail-extras (&key (inner :as list)) (defcomp ~detail/extras (&key (inner :as list))
(div :class "mt-2 space-y-1 text-sm text-stone-600" inner)) (div :class "mt-2 space-y-1 text-sm text-stone-600" inner))
(defcomp ~market-detail-desc-short (&key (text :as string)) (defcomp ~detail/desc-short (&key (text :as string))
(p :class "leading-relaxed text-lg" text)) (p :class "leading-relaxed text-lg" text))
(defcomp ~market-detail-desc-html (&key (html :as string)) (defcomp ~detail/desc-html (&key (html :as string))
(div :class "max-w-none text-sm leading-relaxed" (~rich-text :html html))) (div :class "max-w-none text-sm leading-relaxed" (~rich-text :html html)))
(defcomp ~market-detail-desc-wrapper (&key (inner :as list)) (defcomp ~detail/desc-wrapper (&key (inner :as list))
(div :class "mt-4 text-stone-800 space-y-3" inner)) (div :class "mt-4 text-stone-800 space-y-3" inner))
(defcomp ~market-detail-section (&key (title :as string) (html :as string)) (defcomp ~detail/section (&key (title :as string) (html :as string))
(details :class "group rounded-xl border bg-white shadow-sm open:shadow p-0" (details :class "group rounded-xl border bg-white shadow-sm open:shadow p-0"
(summary :class "cursor-pointer select-none px-4 py-3 flex items-center justify-between" (summary :class "cursor-pointer select-none px-4 py-3 flex items-center justify-between"
(span :class "font-medium" title) (span :class "font-medium" title)
(span :class "ml-2 text-xl transition-transform group-open:rotate-180" "\u2304")) (span :class "ml-2 text-xl transition-transform group-open:rotate-180" "\u2304"))
(div :class "px-4 pb-4 max-w-none text-sm leading-relaxed" (~rich-text :html html)))) (div :class "px-4 pb-4 max-w-none text-sm leading-relaxed" (~rich-text :html html))))
(defcomp ~market-detail-sections (&key (items :as list)) (defcomp ~detail/sections (&key (items :as list))
(div :class "mt-8 space-y-3" items)) (div :class "mt-8 space-y-3" items))
(defcomp ~market-detail-right-col (&key (inner :as list)) (defcomp ~detail/right-col (&key (inner :as list))
(div :class "md:col-span-3" inner)) (div :class "md:col-span-3" inner))
(defcomp ~market-detail-layout (&key (gallery :as list) (stickers :as list?) (details :as list)) (defcomp ~detail/layout (&key (gallery :as list) (stickers :as list?) (details :as list))
(<> (div :class "mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" :data-gallery-root "" (<> (div :class "mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" :data-gallery-root ""
(div :class "md:col-span-2" gallery stickers) (div :class "md:col-span-2" gallery stickers)
details) details)
(div :class "pb-8"))) (div :class "pb-8")))
(defcomp ~market-landing-excerpt (&key (text :as string)) (defcomp ~detail/landing-excerpt (&key (text :as string))
(div :class "w-full text-center italic text-3xl p-2" text)) (div :class "w-full text-center italic text-3xl p-2" text))
(defcomp ~market-landing-image (&key (src :as string)) (defcomp ~detail/landing-image (&key (src :as string))
(div :class "mb-3 flex justify-center" (div :class "mb-3 flex justify-center"
(img :src src :alt "" :class "rounded-lg w-full md:w-3/4 object-cover"))) (img :src src :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
(defcomp ~market-landing-html (&key (html :as string)) (defcomp ~detail/landing-html (&key (html :as string))
(div :class "blog-content p-2" (~rich-text :html html))) (div :class "blog-content p-2" (~rich-text :html html)))
(defcomp ~market-landing-content (&key (inner :as list)) (defcomp ~detail/landing-content (&key (inner :as list))
(<> (article :class "relative w-full" inner) (div :class "pb-8"))) (<> (article :class "relative w-full" inner) (div :class "pb-8")))
@@ -99,64 +99,64 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Gallery section from pre-computed data ;; Gallery section from pre-computed data
(defcomp ~market-detail-gallery-from-data (&key (images :as list?) (labels :as list?) (brand :as string) (like-data :as dict?) (has-nav-buttons :as boolean) (thumbs :as list?)) (defcomp ~detail/gallery-from-data (&key (images :as list?) (labels :as list?) (brand :as string) (like-data :as dict?) (has-nav-buttons :as boolean) (thumbs :as list?))
(let ((like-sx (when like-data (let ((like-sx (when like-data
(~market-like-button (~cards/like-button
:form-id (get like-data "form-id") :action (get like-data "action") :form-id (get like-data "form-id") :action (get like-data "action")
:slug (get like-data "slug") :csrf (get like-data "csrf") :slug (get like-data "slug") :csrf (get like-data "csrf")
:icon-cls (get like-data "icon-cls"))))) :icon-cls (get like-data "icon-cls")))))
(if images (if images
(<> (<>
(~market-detail-gallery (~detail/gallery
:inner (~market-detail-gallery-inner :inner (~detail/gallery-inner
:like like-sx :like like-sx
:image (get (first images) "src") :alt (get (first images) "alt") :image (get (first images) "src") :alt (get (first images) "alt")
:labels (when labels :labels (when labels
(<> (map (lambda (src) (~market-label-overlay :src src)) labels))) (<> (map (lambda (src) (~cards/label-overlay :src src)) labels)))
:brand brand) :brand brand)
:nav (when has-nav-buttons (~market-detail-nav-buttons))) :nav (when has-nav-buttons (~detail/nav-buttons)))
(when thumbs (when thumbs
(~market-detail-thumbs :thumbs (~detail/thumbs :thumbs
(<> (map (lambda (t) (<> (map (lambda (t)
(~market-detail-thumb (~detail/thumb
:title (get t "title") :src (get t "src") :alt (get t "alt"))) :title (get t "title") :src (get t "src") :alt (get t "alt")))
thumbs))))) thumbs)))))
(~market-detail-no-image :like like-sx)))) (~detail/no-image :like like-sx))))
;; Right column details from data ;; Right column details from data
(defcomp ~market-detail-info-from-data (&key (extras :as list?) (desc-short :as string?) (desc-html :as string?) (sections :as list?)) (defcomp ~detail/info-from-data (&key (extras :as list?) (desc-short :as string?) (desc-html :as string?) (sections :as list?))
(~market-detail-right-col :inner (~detail/right-col :inner
(<> (<>
(when extras (when extras
(~market-detail-extras :inner (~detail/extras :inner
(<> (map (lambda (e) (<> (map (lambda (e)
(if (= (get e "type") "unit-price") (if (= (get e "type") "unit-price")
(~market-detail-unit-price :price (get e "value")) (~detail/unit-price :price (get e "value"))
(~market-detail-case-size :size (get e "value")))) (~detail/case-size :size (get e "value"))))
extras)))) extras))))
(when (or desc-short desc-html) (when (or desc-short desc-html)
(~market-detail-desc-wrapper :inner (~detail/desc-wrapper :inner
(<> (when desc-short (~market-detail-desc-short :text desc-short)) (<> (when desc-short (~detail/desc-short :text desc-short))
(when desc-html (~market-detail-desc-html :html desc-html))))) (when desc-html (~detail/desc-html :html desc-html)))))
(when sections (when sections
(~market-detail-sections :items (~detail/sections :items
(<> (map (lambda (s) (<> (map (lambda (s)
(~market-detail-section :title (get s "title") :html (get s "html"))) (~detail/section :title (get s "title") :html (get s "html")))
sections))))))) sections)))))))
;; Full product detail layout from data ;; Full product detail layout from data
(defcomp ~market-product-detail-from-data (&key (images :as list?) (labels :as list?) (brand :as string) (like-data :as dict?) (defcomp ~detail/product-detail-from-data (&key (images :as list?) (labels :as list?) (brand :as string) (like-data :as dict?)
(has-nav-buttons :as boolean) (thumbs :as list?) (sticker-items :as list?) (has-nav-buttons :as boolean) (thumbs :as list?) (sticker-items :as list?)
(extras :as list?) (desc-short :as string?) (desc-html :as string?) (sections :as list?)) (extras :as list?) (desc-short :as string?) (desc-html :as string?) (sections :as list?))
(~market-detail-layout (~detail/layout
:gallery (~market-detail-gallery-from-data :gallery (~detail/gallery-from-data
:images images :labels labels :brand brand :like-data like-data :images images :labels labels :brand brand :like-data like-data
:has-nav-buttons has-nav-buttons :thumbs thumbs) :has-nav-buttons has-nav-buttons :thumbs thumbs)
:stickers (when sticker-items :stickers (when sticker-items
(~market-detail-stickers :items (~detail/stickers :items
(<> (map (lambda (s) (<> (map (lambda (s)
(~market-detail-sticker :src (get s "src") :name (get s "name"))) (~detail/sticker :src (get s "src") :name (get s "name")))
sticker-items)))) sticker-items))))
:details (~market-detail-info-from-data :details (~detail/info-from-data
:extras extras :desc-short desc-short :desc-html desc-html :extras extras :desc-short desc-short :desc-html desc-html
:sections sections))) :sections sections)))

View File

@@ -1,73 +1,73 @@
;; Market filter components ;; Market filter components
(defcomp ~market-filter-sort-item (&key href hx-select ring-cls src label) (defcomp ~filters/sort-item (&key href hx-select ring-cls src label)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls) :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls)
(img :src src :alt label :class "w-10 h-10") (img :src src :alt label :class "w-10 h-10")
(span :class "text-xs" label))) (span :class "text-xs" label)))
(defcomp ~market-filter-sort-row (&key items) (defcomp ~filters/sort-row (&key items)
(div :class "flex flex-row gap-2 justify-center p-1" (div :class "flex flex-row gap-2 justify-center p-1"
items)) items))
(defcomp ~market-filter-like (&key href hx-select icon-cls size-cls) (defcomp ~filters/like (&key href hx-select icon-cls size-cls)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class "flex flex-col items-center gap-1 p-1 cursor-pointer" :class "flex flex-col items-center gap-1 p-1 cursor-pointer"
(i :aria-hidden "true" :class (str icon-cls " " size-cls " leading-none")))) (i :aria-hidden "true" :class (str icon-cls " " size-cls " leading-none"))))
(defcomp ~market-filter-label-item (&key href hx-select ring-cls src name) (defcomp ~filters/label-item (&key href hx-select ring-cls src name)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls) :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls)
(img :src src :alt name :class "w-10 h-10"))) (img :src src :alt name :class "w-10 h-10")))
(defcomp ~market-filter-sticker-item (&key href hx-select ring-cls src name count-cls count) (defcomp ~filters/sticker-item (&key href hx-select ring-cls src name count-cls count)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls) :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls)
(img :src src :alt name :class "w-6 h-6") (img :src src :alt name :class "w-6 h-6")
(span :class count-cls count))) (span :class count-cls count)))
(defcomp ~market-filter-stickers-row (&key items) (defcomp ~filters/stickers-row (&key items)
(div :class "flex flex-wrap gap-2 justify-center p-1" (div :class "flex flex-wrap gap-2 justify-center p-1"
items)) items))
(defcomp ~market-filter-brand-item (&key href hx-select bg-cls name-cls name count) (defcomp ~filters/brand-item (&key href hx-select bg-cls name-cls name count)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "flex flex-row items-center gap-2 px-2 py-1 rounded hover:bg-stone-100" bg-cls) :class (str "flex flex-row items-center gap-2 px-2 py-1 rounded hover:bg-stone-100" bg-cls)
(div :class name-cls name) (div :class name-cls count))) (div :class name-cls name) (div :class name-cls count)))
(defcomp ~market-filter-brands-panel (&key items) (defcomp ~filters/brands-panel (&key items)
(div :class "space-y-1 p-2" (div :class "space-y-1 p-2"
items)) items))
(defcomp ~market-filter-category-label (&key label) (defcomp ~filters/category-label (&key label)
(div :class "mb-4" (div :class "text-2xl uppercase tracking-wide text-black-500" label))) (div :class "mb-4" (div :class "text-2xl uppercase tracking-wide text-black-500" label)))
(defcomp ~market-filter-like-labels-nav (&key content inner) (defcomp ~filters/like-labels-nav (&key content inner)
(nav :aria-label "like" :class "flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1" (nav :aria-label "like" :class "flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1"
(if content content (when inner inner)))) (if content content (when inner inner))))
(defcomp ~market-desktop-category-summary (&key content inner) (defcomp ~filters/desktop-category-summary (&key content inner)
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" (div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
(if content content (when inner inner)))) (if content content (when inner inner))))
(defcomp ~market-desktop-brand-summary (&key inner) (defcomp ~filters/desktop-brand-summary (&key inner)
(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML" inner)) (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML" inner))
(defcomp ~market-filter-subcategory-item (&key href hx-select active-cls name) (defcomp ~filters/subcategory-item (&key href hx-select active-cls name)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "block px-2 py-1 rounded hover:bg-stone-100" active-cls) :class (str "block px-2 py-1 rounded hover:bg-stone-100" active-cls)
name)) name))
(defcomp ~market-filter-subcategory-panel (&key items) (defcomp ~filters/subcategory-panel (&key items)
(div :class "mt-4 space-y-1" items)) (div :class "mt-4 space-y-1" items))
(defcomp ~market-mobile-clear-filters (&key href hx-select) (defcomp ~filters/mobile-clear-filters (&key href hx-select)
(div :class "flex flex-row justify-center" (div :class "flex flex-row justify-center"
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
@@ -75,10 +75,10 @@
:class "flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer" :class "flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer"
(span :class "mt-1 leading-none tabular-nums" "clear filters")))) (span :class "mt-1 leading-none tabular-nums" "clear filters"))))
(defcomp ~market-mobile-like-labels-row (&key inner) (defcomp ~filters/mobile-like-labels-row (&key inner)
(div :class "flex flex-row gap-2 justify-center items-center" inner)) (div :class "flex flex-row gap-2 justify-center items-center" inner))
(defcomp ~market-mobile-filter-summary (&key search-bar chips filter) (defcomp ~filters/mobile-filter-summary (&key search-bar chips filter)
(details :class "md:hidden group" :id "/filter" (details :class "md:hidden group" :id "/filter"
(summary :class "cursor-pointer select-none" :id "filter-summary-mobile" (summary :class "cursor-pointer select-none" :id "filter-summary-mobile"
search-bar search-bar
@@ -87,40 +87,40 @@
(div :id "filter-details-mobile" :style "display:contents" (div :id "filter-details-mobile" :style "display:contents"
filter))) filter)))
(defcomp ~market-mobile-chips-row (&key inner) (defcomp ~filters/mobile-chips-row (&key inner)
(div :class "flex flex-row items-start gap-2" inner)) (div :class "flex flex-row items-start gap-2" inner))
(defcomp ~market-mobile-chip-sort (&key src label) (defcomp ~filters/mobile-chip-sort (&key src label)
(ul :class "relative inline-flex items-center justify-center gap-2" (ul :class "relative inline-flex items-center justify-center gap-2"
(li :role "listitem" (img :src src :alt label :class "w-10 h-10")))) (li :role "listitem" (img :src src :alt label :class "w-10 h-10"))))
(defcomp ~market-mobile-chip-liked-icon () (defcomp ~filters/mobile-chip-liked-icon ()
(i :aria-hidden "true" :class "fa-solid fa-heart text-red-500 text-[40px] leading-none")) (i :aria-hidden "true" :class "fa-solid fa-heart text-red-500 text-[40px] leading-none"))
(defcomp ~market-mobile-chip-count (&key cls count) (defcomp ~filters/mobile-chip-count (&key cls count)
(div :class (str cls " mt-1 leading-none tabular-nums") count)) (div :class (str cls " mt-1 leading-none tabular-nums") count))
(defcomp ~market-mobile-chip-liked (&key inner) (defcomp ~filters/mobile-chip-liked (&key inner)
(div :class "flex flex-col items-center gap-1 pb-1" inner)) (div :class "flex flex-col items-center gap-1 pb-1" inner))
(defcomp ~market-mobile-chip-image (&key src name) (defcomp ~filters/mobile-chip-image (&key src name)
(img :src src :alt name :class "w-10 h-10")) (img :src src :alt name :class "w-10 h-10"))
(defcomp ~market-mobile-chip-item (&key inner) (defcomp ~filters/mobile-chip-item (&key inner)
(li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" inner)) (li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" inner))
(defcomp ~market-mobile-chip-list (&key items) (defcomp ~filters/mobile-chip-list (&key items)
(ul :class "relative inline-flex items-center justify-center gap-2" items)) (ul :class "relative inline-flex items-center justify-center gap-2" items))
(defcomp ~market-mobile-chip-brand (&key name count) (defcomp ~filters/mobile-chip-brand (&key name count)
(li :role "listitem" :class "flex flex-row items-center gap-2" (li :role "listitem" :class "flex flex-row items-center gap-2"
(div :class "text-md" name) (div :class "text-md" count))) (div :class "text-md" name) (div :class "text-md" count)))
(defcomp ~market-mobile-chip-brand-zero (&key name) (defcomp ~filters/mobile-chip-brand-zero (&key name)
(li :role "listitem" :class "flex flex-row items-center gap-2" (li :role "listitem" :class "flex flex-row items-center gap-2"
(div :class "text-md text-red-500" name) (div :class "text-xl text-red-500" "0"))) (div :class "text-md text-red-500" name) (div :class "text-xl text-red-500" "0")))
(defcomp ~market-mobile-chip-brand-list (&key items) (defcomp ~filters/mobile-chip-brand-list (&key items)
(ul items)) (ul items))
@@ -129,160 +129,160 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Sort option stickers from data ;; Sort option stickers from data
(defcomp ~market-filter-sort-from-data (&key items) (defcomp ~filters/sort-from-data (&key items)
(~market-filter-sort-row :items (~filters/sort-row :items
(<> (map (lambda (s) (<> (map (lambda (s)
(~market-filter-sort-item (~filters/sort-item
:href (get s "href") :hx-select (get s "hx-select") :href (get s "href") :hx-select (get s "hx-select")
:ring-cls (get s "ring-cls") :src (get s "src") :label (get s "label"))) :ring-cls (get s "ring-cls") :src (get s "src") :label (get s "label")))
items)))) items))))
;; Like filter from data ;; Like filter from data
(defcomp ~market-filter-like-from-data (&key href hx-select liked mobile) (defcomp ~filters/like-from-data (&key href hx-select liked mobile)
(~market-filter-like (~filters/like
:href href :hx-select hx-select :href href :hx-select hx-select
:icon-cls (if liked "fa-solid fa-heart text-red-500" "fa-regular fa-heart text-stone-400") :icon-cls (if liked "fa-solid fa-heart text-red-500" "fa-regular fa-heart text-stone-400")
:size-cls (if mobile "text-[40px]" "text-2xl"))) :size-cls (if mobile "text-[40px]" "text-2xl")))
;; Label filter items from data ;; Label filter items from data
(defcomp ~market-filter-labels-from-data (&key items hx-select) (defcomp ~filters/labels-from-data (&key items hx-select)
(<> (map (lambda (lb) (<> (map (lambda (lb)
(~market-filter-label-item (~filters/label-item
:href (get lb "href") :hx-select hx-select :href (get lb "href") :hx-select hx-select
:ring-cls (get lb "ring-cls") :src (get lb "src") :name (get lb "name"))) :ring-cls (get lb "ring-cls") :src (get lb "src") :name (get lb "name")))
items))) items)))
;; Sticker filter items from data ;; Sticker filter items from data
(defcomp ~market-filter-stickers-from-data (&key items hx-select) (defcomp ~filters/stickers-from-data (&key items hx-select)
(~market-filter-stickers-row :items (~filters/stickers-row :items
(<> (map (lambda (st) (<> (map (lambda (st)
(~market-filter-sticker-item (~filters/sticker-item
:href (get st "href") :hx-select hx-select :href (get st "href") :hx-select hx-select
:ring-cls (get st "ring-cls") :src (get st "src") :name (get st "name") :ring-cls (get st "ring-cls") :src (get st "src") :name (get st "name")
:count-cls (get st "count-cls") :count (get st "count"))) :count-cls (get st "count-cls") :count (get st "count")))
items)))) items))))
;; Brand filter items from data ;; Brand filter items from data
(defcomp ~market-filter-brands-from-data (&key items hx-select) (defcomp ~filters/brands-from-data (&key items hx-select)
(~market-filter-brands-panel :items (~filters/brands-panel :items
(<> (map (lambda (br) (<> (map (lambda (br)
(~market-filter-brand-item (~filters/brand-item
:href (get br "href") :hx-select hx-select :href (get br "href") :hx-select hx-select
:bg-cls (get br "bg-cls") :name-cls (get br "name-cls") :bg-cls (get br "bg-cls") :name-cls (get br "name-cls")
:name (get br "name") :count (get br "count"))) :name (get br "name") :count (get br "count")))
items)))) items))))
;; Subcategory selector from data ;; Subcategory selector from data
(defcomp ~market-filter-subcategories-from-data (&key items hx-select all-href current-sub) (defcomp ~filters/subcategories-from-data (&key items hx-select all-href current-sub)
(~market-filter-subcategory-panel :items (~filters/subcategory-panel :items
(<> (<>
(~market-filter-subcategory-item (~filters/subcategory-item
:href all-href :hx-select hx-select :href all-href :hx-select hx-select
:active-cls (if (not current-sub) " bg-stone-200 font-medium" "") :active-cls (if (not current-sub) " bg-stone-200 font-medium" "")
:name "All") :name "All")
(map (lambda (sub) (map (lambda (sub)
(~market-filter-subcategory-item (~filters/subcategory-item
:href (get sub "href") :hx-select hx-select :href (get sub "href") :hx-select hx-select
:active-cls (get sub "active-cls") :name (get sub "name"))) :active-cls (get sub "active-cls") :name (get sub "name")))
items)))) items))))
;; Desktop filter panel from data ;; Desktop filter panel from data
(defcomp ~market-desktop-filter-from-data (&key search-sx category-label (defcomp ~filters/desktop-filter-from-data (&key search-sx category-label
sort-data like-data label-data sort-data like-data label-data
sticker-data brand-data sub-data hx-select) sticker-data brand-data sub-data hx-select)
(<> (<>
search-sx search-sx
(~market-desktop-category-summary :inner (~filters/desktop-category-summary :inner
(<> (<>
(~market-filter-category-label :label category-label) (~filters/category-label :label category-label)
(when sort-data (~market-filter-sort-from-data :items sort-data)) (when sort-data (~filters/sort-from-data :items sort-data))
(~market-filter-like-labels-nav :inner (~filters/like-labels-nav :inner
(<> (<>
(~market-filter-like-from-data (~filters/like-from-data
:href (get like-data "href") :hx-select hx-select :href (get like-data "href") :hx-select hx-select
:liked (get like-data "liked") :mobile false) :liked (get like-data "liked") :mobile false)
(when label-data (when label-data
(~market-filter-labels-from-data :items label-data :hx-select hx-select)))) (~filters/labels-from-data :items label-data :hx-select hx-select))))
(when sticker-data (when sticker-data
(~market-filter-stickers-from-data :items sticker-data :hx-select hx-select)) (~filters/stickers-from-data :items sticker-data :hx-select hx-select))
(when sub-data (when sub-data
(~market-filter-subcategories-from-data (~filters/subcategories-from-data
:items (get sub-data "items") :hx-select hx-select :items (get sub-data "items") :hx-select hx-select
:all-href (get sub-data "all-href") :all-href (get sub-data "all-href")
:current-sub (get sub-data "current-sub"))))) :current-sub (get sub-data "current-sub")))))
(~market-desktop-brand-summary (~filters/desktop-brand-summary
:inner (when brand-data :inner (when brand-data
(~market-filter-brands-from-data :items brand-data :hx-select hx-select))))) (~filters/brands-from-data :items brand-data :hx-select hx-select)))))
;; Mobile filter chips from active filter data ;; Mobile filter chips from active filter data
(defcomp ~market-mobile-chips-from-data (&key sort-chip liked-chip label-chips sticker-chips brand-chips) (defcomp ~filters/mobile-chips-from-data (&key sort-chip liked-chip label-chips sticker-chips brand-chips)
(~market-mobile-chips-row :inner (~filters/mobile-chips-row :inner
(<> (<>
(when sort-chip (when sort-chip
(~market-mobile-chip-sort :src (get sort-chip "src") :label (get sort-chip "label"))) (~filters/mobile-chip-sort :src (get sort-chip "src") :label (get sort-chip "label")))
(when liked-chip (when liked-chip
(~market-mobile-chip-liked :inner (~filters/mobile-chip-liked :inner
(<> (<>
(~market-mobile-chip-liked-icon) (~filters/mobile-chip-liked-icon)
(when (get liked-chip "count") (when (get liked-chip "count")
(~market-mobile-chip-count (~filters/mobile-chip-count
:cls (get liked-chip "count-cls") :count (get liked-chip "count")))))) :cls (get liked-chip "count-cls") :count (get liked-chip "count"))))))
(when label-chips (when label-chips
(~market-mobile-chip-list :items (~filters/mobile-chip-list :items
(<> (map (lambda (lc) (<> (map (lambda (lc)
(~market-mobile-chip-item :inner (~filters/mobile-chip-item :inner
(<> (<>
(~market-mobile-chip-image :src (get lc "src") :name (get lc "name")) (~filters/mobile-chip-image :src (get lc "src") :name (get lc "name"))
(when (get lc "count") (when (get lc "count")
(~market-mobile-chip-count :cls (get lc "count-cls") :count (get lc "count")))))) (~filters/mobile-chip-count :cls (get lc "count-cls") :count (get lc "count"))))))
label-chips)))) label-chips))))
(when sticker-chips (when sticker-chips
(~market-mobile-chip-list :items (~filters/mobile-chip-list :items
(<> (map (lambda (sc) (<> (map (lambda (sc)
(~market-mobile-chip-item :inner (~filters/mobile-chip-item :inner
(<> (<>
(~market-mobile-chip-image :src (get sc "src") :name (get sc "name")) (~filters/mobile-chip-image :src (get sc "src") :name (get sc "name"))
(when (get sc "count") (when (get sc "count")
(~market-mobile-chip-count :cls (get sc "count-cls") :count (get sc "count")))))) (~filters/mobile-chip-count :cls (get sc "count-cls") :count (get sc "count"))))))
sticker-chips)))) sticker-chips))))
(when brand-chips (when brand-chips
(~market-mobile-chip-brand-list :items (~filters/mobile-chip-brand-list :items
(<> (map (lambda (bc) (<> (map (lambda (bc)
(if (get bc "has-count") (if (get bc "has-count")
(~market-mobile-chip-brand :name (get bc "name") :count (get bc "count")) (~filters/mobile-chip-brand :name (get bc "name") :count (get bc "count"))
(~market-mobile-chip-brand-zero :name (get bc "name")))) (~filters/mobile-chip-brand-zero :name (get bc "name"))))
brand-chips))))))) brand-chips)))))))
;; Mobile filter content (expanded panel) from data ;; Mobile filter content (expanded panel) from data
(defcomp ~market-mobile-filter-content-from-data (&key sort-data like-data label-data (defcomp ~filters/mobile-filter-content-from-data (&key sort-data like-data label-data
sticker-data brand-data clear-href hx-select) sticker-data brand-data clear-href hx-select)
(<> (<>
(when sort-data (~market-filter-sort-from-data :items sort-data)) (when sort-data (~filters/sort-from-data :items sort-data))
(when clear-href (when clear-href
(~market-mobile-clear-filters :href clear-href :hx-select hx-select)) (~filters/mobile-clear-filters :href clear-href :hx-select hx-select))
(~market-mobile-like-labels-row :inner (~filters/mobile-like-labels-row :inner
(<> (<>
(~market-filter-like-from-data (~filters/like-from-data
:href (get like-data "href") :hx-select hx-select :href (get like-data "href") :hx-select hx-select
:liked (get like-data "liked") :mobile true) :liked (get like-data "liked") :mobile true)
(when label-data (when label-data
(~market-filter-labels-from-data :items label-data :hx-select hx-select)))) (~filters/labels-from-data :items label-data :hx-select hx-select))))
(when sticker-data (when sticker-data
(~market-filter-stickers-from-data :items sticker-data :hx-select hx-select)) (~filters/stickers-from-data :items sticker-data :hx-select hx-select))
(when brand-data (when brand-data
(~market-filter-brands-from-data :items brand-data :hx-select hx-select)))) (~filters/brands-from-data :items brand-data :hx-select hx-select))))
;; Composite mobile filter — eliminates SxExpr nesting in Python (M2) ;; Composite mobile filter — eliminates SxExpr nesting in Python (M2)
(defcomp ~market-mobile-filter-from-data (&key search-bar (defcomp ~filters/mobile-filter-from-data (&key search-bar
sort-chip liked-chip label-chips sticker-chips brand-chips sort-chip liked-chip label-chips sticker-chips brand-chips
sort-data like-data label-data sticker-data brand-data sort-data like-data label-data sticker-data brand-data
clear-href hx-select) clear-href hx-select)
(~market-mobile-filter-summary (~filters/mobile-filter-summary
:search-bar search-bar :search-bar search-bar
:chips (~market-mobile-chips-from-data :chips (~filters/mobile-chips-from-data
:sort-chip sort-chip :liked-chip liked-chip :sort-chip sort-chip :liked-chip liked-chip
:label-chips label-chips :sticker-chips sticker-chips :brand-chips brand-chips) :label-chips label-chips :sticker-chips sticker-chips :brand-chips brand-chips)
:filter (~market-mobile-filter-content-from-data :filter (~filters/mobile-filter-content-from-data
:sort-data sort-data :like-data like-data :sort-data sort-data :like-data like-data
:label-data label-data :sticker-data sticker-data :brand-data brand-data :label-data label-data :sticker-data sticker-data :brand-data brand-data
:clear-href clear-href :hx-select hx-select))) :clear-href clear-href :hx-select hx-select)))

View File

@@ -1,15 +1,15 @@
;; Market grid and layout components ;; Market grid and layout components
(defcomp ~market-markets-grid (&key cards) (defcomp ~grids/markets-grid (&key cards)
(<> (div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" cards) (div :class "pb-8"))) (<> (div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" cards) (div :class "pb-8")))
(defcomp ~market-product-grid (&key cards) (defcomp ~grids/product-grid (&key cards)
(<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" cards) (div :class "pb-8"))) (<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" cards) (div :class "pb-8")))
(defcomp ~market-admin-content-wrap (&key inner) (defcomp ~grids/admin-content-wrap (&key inner)
(div :id "main-panel" inner)) (div :id "main-panel" inner))
(defcomp ~market-like-toggle-button (&key colour action hx-headers label icon-cls) (defcomp ~grids/like-toggle-button (&key colour action hx-headers label icon-cls)
(button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]") (button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]")
:sx-post action :sx-target "this" :sx-swap "outerHTML" :sx-push-url "false" :sx-post action :sx-target "this" :sx-swap "outerHTML" :sx-push-url "false"
:sx-headers hx-headers :sx-headers hx-headers

View File

@@ -15,7 +15,7 @@
(sel-colours (or (jinja-global "select_colours") ""))) (sel-colours (or (jinja-global "select_colours") "")))
(<> (map (fn (m) (<> (map (fn (m)
(let ((href (app-url "market" (str "/" slug "/" (get m "slug") "/")))) (let ((href (app-url "market" (str "/" slug "/" (get m "slug") "/"))))
(~market-link-nav (~shared:navigation/market-link-nav
:href href :href href
:name (get m "name") :name (get m "name")
:nav-class nav-class :nav-class nav-class

View File

@@ -19,7 +19,7 @@
(if (get product "regular_price") (if (get product "regular_price")
(str (get product "regular_price")) (str (get product "regular_price"))
"")))) ""))))
(~link-card (~shared:fragments/link-card
:title (get product "title") :title (get product "title")
:image (get product "image") :image (get product "image")
:subtitle subtitle :subtitle subtitle
@@ -35,7 +35,7 @@
(if (get product "regular_price") (if (get product "regular_price")
(str (get product "regular_price")) (str (get product "regular_price"))
"")))) ""))))
(~link-card (~shared:fragments/link-card
:title (get product "title") :title (get product "title")
:image (get product "image") :image (get product "image")
:subtitle subtitle :subtitle subtitle

View File

@@ -1,15 +1,15 @@
;; Market header components ;; Market header components
(defcomp ~market-shop-label (&key title top-slug sub-div) (defcomp ~headers/shop-label (&key title top-slug sub-div)
(div :class "font-bold text-xl flex-shrink-0 flex gap-2 items-center" (div :class "font-bold text-xl flex-shrink-0 flex gap-2 items-center"
(div (i :class "fa fa-shop") " " title) (div (i :class "fa fa-shop") " " title)
(div :class "flex flex-col md:flex-row md:gap-2 text-xs" (div :class "flex flex-col md:flex-row md:gap-2 text-xs"
(div top-slug) (when sub-div (div sub-div))))) (div top-slug) (when sub-div (div sub-div)))))
(defcomp ~market-product-label (&key title) (defcomp ~headers/product-label (&key title)
(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div title))) (<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div title)))
(defcomp ~market-admin-link (&key href hx-select) (defcomp ~headers/admin-link (&key href hx-select)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class "px-2 py-1 text-stone-500 hover:text-stone-700" :class "px-2 py-1 text-stone-500 hover:text-stone-700"
@@ -21,42 +21,42 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Desktop category nav from pre-computed category data ;; Desktop category nav from pre-computed category data
(defcomp ~market-desktop-nav-from-data (&key categories hx-select select-colours (defcomp ~headers/desktop-nav-from-data (&key categories hx-select select-colours
all-href all-active admin-href) all-href all-active admin-href)
(~market-desktop-category-nav (~navigation/desktop-category-nav
:links (<> :links (<>
(~market-category-link :href all-href :hx-select hx-select (~navigation/category-link :href all-href :hx-select hx-select
:active all-active :select-colours select-colours :label "All") :active all-active :select-colours select-colours :label "All")
(map (lambda (cat) (map (lambda (cat)
(~market-category-link (~navigation/category-link
:href (get cat "href") :hx-select hx-select :href (get cat "href") :hx-select hx-select
:active (get cat "active") :select-colours select-colours :active (get cat "active") :select-colours select-colours
:label (get cat "label"))) categories)) :label (get cat "label"))) categories))
:admin (when admin-href :admin (when admin-href
(~market-admin-link :href admin-href :hx-select hx-select)))) (~headers/admin-link :href admin-href :hx-select hx-select))))
;; Market-level header row from data ;; Market-level header row from data
(defcomp ~market-header-from-data (&key market-title top-slug sub-slug link-href (defcomp ~headers/from-data (&key market-title top-slug sub-slug link-href
categories hx-select select-colours categories hx-select select-colours
all-href all-active admin-href oob) all-href all-active admin-href oob)
(~menu-row-sx :id "market-row" :level 2 (~shared:layout/menu-row-sx :id "market-row" :level 2
:link-href link-href :link-href link-href
:link-label-content (~market-shop-label :link-label-content (~headers/shop-label
:title market-title :top-slug (or top-slug "") :sub-div sub-slug) :title market-title :top-slug (or top-slug "") :sub-div sub-slug)
:nav (~market-desktop-nav-from-data :nav (~headers/desktop-nav-from-data
:categories categories :hx-select hx-select :select-colours select-colours :categories categories :hx-select hx-select :select-colours select-colours
:all-href all-href :all-active all-active :admin-href admin-href) :all-href all-href :all-active all-active :admin-href admin-href)
:child-id "market-header-child" :child-id "market-header-child"
:oob oob)) :oob oob))
;; Product-level header row from data ;; Product-level header row from data
(defcomp ~market-product-header-from-data (&key title link-href hx-select (defcomp ~headers/product-header-from-data (&key title link-href hx-select
price-data admin-href oob) price-data admin-href oob)
(~menu-row-sx :id "product-row" :level 3 (~shared:layout/menu-row-sx :id "product-row" :level 3
:link-href link-href :link-href link-href
:link-label-content (~market-product-label :title title) :link-label-content (~headers/product-label :title title)
:nav (<> :nav (<>
(~market-prices-header-from-data (~prices/header-from-data
:cart-id (get price-data "cart-id") :cart-id (get price-data "cart-id")
:cart-action (get price-data "cart-action") :cart-action (get price-data "cart-action")
:csrf (get price-data "csrf") :csrf (get price-data "csrf")
@@ -66,13 +66,13 @@
:rp-val (get price-data "rp-val") :rp-str (get price-data "rp-str") :rp-val (get price-data "rp-val") :rp-str (get price-data "rp-str")
:rrp-str (get price-data "rrp-str")) :rrp-str (get price-data "rrp-str"))
(when admin-href (when admin-href
(~market-admin-link :href admin-href :hx-select hx-select))) (~headers/admin-link :href admin-href :hx-select hx-select)))
:child-id "product-header-child" :child-id "product-header-child"
:oob oob)) :oob oob))
;; Product admin header row from data ;; Product admin header row from data
(defcomp ~market-product-admin-header-from-data (&key link-href oob) (defcomp ~headers/product-admin-header-from-data (&key link-href oob)
(~menu-row-sx :id "product-admin-row" :level 4 (~shared:layout/menu-row-sx :id "product-admin-row" :level 4
:link-href link-href :link-label "admin!!" :icon "fa fa-cog" :link-href link-href :link-label "admin!!" :icon "fa fa-cog"
:child-id "product-admin-header-child" :oob oob)) :child-id "product-admin-header-child" :oob oob))

View File

@@ -9,13 +9,13 @@
"Market header row using (market-header-ctx)." "Market header row using (market-header-ctx)."
(quasiquote (quasiquote
(let ((__mctx (market-header-ctx))) (let ((__mctx (market-header-ctx)))
(~menu-row-sx :id "market-row" :level 2 (~shared:layout/menu-row-sx :id "market-row" :level 2
:link-href (get __mctx "link-href") :link-href (get __mctx "link-href")
:link-label-content (~market-shop-label :link-label-content (~headers/shop-label
:title (get __mctx "market-title") :title (get __mctx "market-title")
:top-slug (get __mctx "top-slug") :top-slug (get __mctx "top-slug")
:sub-div (get __mctx "sub-slug")) :sub-div (get __mctx "sub-slug"))
:nav (~market-desktop-nav-from-data :nav (~headers/desktop-nav-from-data
:categories (get __mctx "categories") :categories (get __mctx "categories")
:hx-select (get __mctx "hx-select") :hx-select (get __mctx "hx-select")
:select-colours (get __mctx "select-colours") :select-colours (get __mctx "select-colours")
@@ -29,44 +29,44 @@
;; OOB clear helpers ;; OOB clear helpers
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~market-clear-oob () (defcomp ~layouts/clear-oob ()
"Clear OOB divs for browse level." "Clear OOB divs for browse level."
(<> (<>
(~clear-oob-div :id "product-admin-row") (~shared:layout/clear-oob-div :id "product-admin-row")
(~clear-oob-div :id "product-admin-header-child") (~shared:layout/clear-oob-div :id "product-admin-header-child")
(~clear-oob-div :id "product-row") (~shared:layout/clear-oob-div :id "product-row")
(~clear-oob-div :id "product-header-child") (~shared:layout/clear-oob-div :id "product-header-child")
(~clear-oob-div :id "market-admin-row") (~shared:layout/clear-oob-div :id "market-admin-row")
(~clear-oob-div :id "market-admin-header-child") (~shared:layout/clear-oob-div :id "market-admin-header-child")
(~clear-oob-div :id "post-admin-row") (~shared:layout/clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child"))) (~shared:layout/clear-oob-div :id "post-admin-header-child")))
(defcomp ~market-clear-oob-admin () (defcomp ~layouts/clear-oob-admin ()
"Clear OOB divs for admin level." "Clear OOB divs for admin level."
(<> (<>
(~clear-oob-div :id "product-admin-row") (~shared:layout/clear-oob-div :id "product-admin-row")
(~clear-oob-div :id "product-admin-header-child") (~shared:layout/clear-oob-div :id "product-admin-header-child")
(~clear-oob-div :id "product-row") (~shared:layout/clear-oob-div :id "product-row")
(~clear-oob-div :id "product-header-child"))) (~shared:layout/clear-oob-div :id "product-header-child")))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Browse layout: root + post + market (self-contained) ;; Browse layout: root + post + market (self-contained)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~market-browse-layout-full () (defcomp ~layouts/browse-layout-full ()
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~market-header-auto nil))))) (~market-header-auto nil)))))
(defcomp ~market-browse-layout-oob () (defcomp ~layouts/browse-layout-oob ()
(<> (~post-header-auto true) (<> (~post-header-auto true)
(~oob-header-sx :parent-id "post-header-child" (~shared:layout/oob-header-sx :parent-id "post-header-child"
:row (~market-header-auto nil)) :row (~market-header-auto nil))
(~market-clear-oob) (~layouts/clear-oob)
(~root-header-auto true))) (~root-header-auto true)))
(defcomp ~market-browse-layout-mobile () (defcomp ~layouts/browse-layout-mobile ()
(let ((__mctx (market-header-ctx))) (let ((__mctx (market-header-ctx)))
(get __mctx "mobile-nav"))) (get __mctx "mobile-nav")))
@@ -74,18 +74,18 @@
;; Market admin layout: root + post + market + post-admin (self-contained) ;; Market admin layout: root + post + market + post-admin (self-contained)
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~market-admin-layout-full (&key selected) (defcomp ~layouts/admin-layout-full (&key selected)
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~post-header-auto nil) :inner (<> (~post-header-auto nil)
(~market-header-auto nil) (~market-header-auto nil)
(~post-admin-header-auto nil selected))))) (~post-admin-header-auto nil selected)))))
(defcomp ~market-admin-layout-oob (&key selected) (defcomp ~layouts/admin-layout-oob (&key selected)
(<> (~market-header-auto true) (<> (~market-header-auto true)
(~oob-header-sx :parent-id "market-header-child" (~shared:layout/oob-header-sx :parent-id "market-header-child"
:row (~post-admin-header-auto nil selected)) :row (~post-admin-header-auto nil selected))
(~market-clear-oob-admin) (~layouts/clear-oob-admin)
(~root-header-auto true))) (~root-header-auto true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
@@ -93,46 +93,46 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Product layout: root + post + market + product ;; Product layout: root + post + market + product
(defcomp ~market-product-layout-full (&key post-header market-header product-header) (defcomp ~layouts/product-layout-full (&key post-header market-header product-header)
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx :inner (<> post-header market-header product-header)))) (~shared:layout/header-child-sx :inner (<> post-header market-header product-header))))
;; Product admin layout: root + post + market + product + admin ;; Product admin layout: root + post + market + product + admin
(defcomp ~market-product-admin-layout-full (&key post-header market-header product-header admin-header) (defcomp ~layouts/product-admin-layout-full (&key post-header market-header product-header admin-header)
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx :inner (<> post-header market-header product-header admin-header)))) (~shared:layout/header-child-sx :inner (<> post-header market-header product-header admin-header))))
;; OOB wrappers — compose headers + clear divs in sx (no Python concatenation) ;; OOB wrappers — compose headers + clear divs in sx (no Python concatenation)
(defcomp ~market-oob-wrap (&key parts) (defcomp ~layouts/oob-wrap (&key parts)
(<> parts)) (<> parts))
(defcomp ~market-clear-product-oob () (defcomp ~layouts/clear-product-oob ()
"Clear admin-level OOB divs when rendering product detail." "Clear admin-level OOB divs when rendering product detail."
(<> (<>
(~clear-oob-div :id "product-admin-row") (~shared:layout/clear-oob-div :id "product-admin-row")
(~clear-oob-div :id "product-admin-header-child") (~shared:layout/clear-oob-div :id "product-admin-header-child")
(~clear-oob-div :id "market-admin-row") (~shared:layout/clear-oob-div :id "market-admin-row")
(~clear-oob-div :id "market-admin-header-child") (~shared:layout/clear-oob-div :id "market-admin-header-child")
(~clear-oob-div :id "post-admin-row") (~shared:layout/clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child"))) (~shared:layout/clear-oob-div :id "post-admin-header-child")))
(defcomp ~market-clear-product-admin-oob () (defcomp ~layouts/clear-product-admin-oob ()
"Clear deeper OOB divs when rendering product admin." "Clear deeper OOB divs when rendering product admin."
(<> (<>
(~clear-oob-div :id "market-admin-row") (~shared:layout/clear-oob-div :id "market-admin-row")
(~clear-oob-div :id "market-admin-header-child") (~shared:layout/clear-oob-div :id "market-admin-header-child")
(~clear-oob-div :id "post-admin-row") (~shared:layout/clear-oob-div :id "post-admin-row")
(~clear-oob-div :id "post-admin-header-child"))) (~shared:layout/clear-oob-div :id "post-admin-header-child")))
(defcomp ~market-product-oob (&key market-header oob-header) (defcomp ~layouts/product-oob (&key market-header oob-header)
"Product detail OOB: market header + product header + clear deeper." "Product detail OOB: market header + product header + clear deeper."
(<> market-header oob-header (~market-clear-product-oob))) (<> market-header oob-header (~layouts/clear-product-oob)))
(defcomp ~market-product-admin-oob (&key product-header oob-header) (defcomp ~layouts/product-admin-oob (&key product-header oob-header)
"Product admin OOB: product header + admin header + clear deeper." "Product admin OOB: product header + admin header + clear deeper."
(<> product-header oob-header (~market-clear-product-admin-oob))) (<> product-header oob-header (~layouts/clear-product-admin-oob)))
;; Content wrappers ;; Content wrappers
(defcomp ~market-content-padded (&key content) (defcomp ~layouts/content-padded (&key content)
(<> content (div :class "pb-8"))) (<> content (div :class "pb-8")))

View File

@@ -1,21 +1,21 @@
;; Market meta/SEO components ;; Market meta/SEO components
(defcomp ~market-meta-title (&key (title :as string)) (defcomp ~meta/title (&key (title :as string))
(title title)) (title title))
(defcomp ~market-meta-description (&key (description :as string)) (defcomp ~meta/description (&key (description :as string))
(meta :name "description" :content description)) (meta :name "description" :content description))
(defcomp ~market-meta-canonical (&key (href :as string)) (defcomp ~meta/canonical (&key (href :as string))
(link :rel "canonical" :href href)) (link :rel "canonical" :href href))
(defcomp ~market-meta-og (&key (property :as string) (content :as string)) (defcomp ~meta/og (&key (property :as string) (content :as string))
(meta :property property :content content)) (meta :property property :content content))
(defcomp ~market-meta-twitter (&key (name :as string) (content :as string)) (defcomp ~meta/twitter (&key (name :as string) (content :as string))
(meta :name name :content content)) (meta :name name :content content))
(defcomp ~market-meta-jsonld (&key (json :as string)) (defcomp ~meta/jsonld (&key (json :as string))
(script :type "application/ld+json" (~rich-text :html json))) (script :type "application/ld+json" (~rich-text :html json)))
@@ -23,30 +23,30 @@
;; Composition: all product meta tags from data ;; Composition: all product meta tags from data
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~market-product-meta-from-data (&key (title :as string) (description :as string) (canonical :as string?) (defcomp ~meta/product-meta-from-data (&key (title :as string) (description :as string) (canonical :as string?)
(image-url :as string?) (image-url :as string?)
(site-title :as string) (brand :as string?) (price :as string?) (price-currency :as string?) (site-title :as string) (brand :as string?) (price :as string?) (price-currency :as string?)
(jsonld-json :as string)) (jsonld-json :as string))
(<> (<>
(~market-meta-title :title title) (~meta/title :title title)
(~market-meta-description :description description) (~meta/description :description description)
(when canonical (~market-meta-canonical :href canonical)) (when canonical (~meta/canonical :href canonical))
;; OpenGraph ;; OpenGraph
(~market-meta-og :property "og:site_name" :content site-title) (~meta/og :property "og:site_name" :content site-title)
(~market-meta-og :property "og:type" :content "product") (~meta/og :property "og:type" :content "product")
(~market-meta-og :property "og:title" :content title) (~meta/og :property "og:title" :content title)
(~market-meta-og :property "og:description" :content description) (~meta/og :property "og:description" :content description)
(when canonical (~market-meta-og :property "og:url" :content canonical)) (when canonical (~meta/og :property "og:url" :content canonical))
(when image-url (~market-meta-og :property "og:image" :content image-url)) (when image-url (~meta/og :property "og:image" :content image-url))
(when (and price price-currency) (when (and price price-currency)
(<> (~market-meta-og :property "product:price:amount" :content price) (<> (~meta/og :property "product:price:amount" :content price)
(~market-meta-og :property "product:price:currency" :content price-currency))) (~meta/og :property "product:price:currency" :content price-currency)))
(when brand (~market-meta-og :property "product:brand" :content brand)) (when brand (~meta/og :property "product:brand" :content brand))
;; Twitter ;; Twitter
(~market-meta-twitter :name "twitter:card" (~meta/twitter :name "twitter:card"
:content (if image-url "summary_large_image" "summary")) :content (if image-url "summary_large_image" "summary"))
(~market-meta-twitter :name "twitter:title" :content title) (~meta/twitter :name "twitter:title" :content title)
(~market-meta-twitter :name "twitter:description" :content description) (~meta/twitter :name "twitter:description" :content description)
(when image-url (~market-meta-twitter :name "twitter:image" :content image-url)) (when image-url (~meta/twitter :name "twitter:image" :content image-url))
;; JSON-LD ;; JSON-LD
(~market-meta-jsonld :json jsonld-json))) (~meta/jsonld :json jsonld-json)))

View File

@@ -1,6 +1,6 @@
;; Market navigation components ;; Market navigation components
(defcomp ~market-category-link (&key (href :as string) (hx-select :as string) (active :as boolean) (select-colours :as string) (label :as string)) (defcomp ~navigation/category-link (&key (href :as string) (hx-select :as string) (active :as boolean) (select-colours :as string) (label :as string))
(div :class "relative nav-group" (div :class "relative nav-group"
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
@@ -8,27 +8,27 @@
:class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " select-colours) :class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " select-colours)
label))) label)))
(defcomp ~market-desktop-category-nav (&key (links :as list) (admin :as list?)) (defcomp ~navigation/desktop-category-nav (&key (links :as list) (admin :as list?))
(nav :class "hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center" (nav :class "hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center"
links admin)) links admin))
(defcomp ~market-mobile-nav-wrapper (&key (items :as list)) (defcomp ~navigation/mobile-nav-wrapper (&key (items :as list))
(div :class "px-4 py-2" (div :class "divide-y" items))) (div :class "px-4 py-2" (div :class "divide-y" items)))
(defcomp ~market-mobile-all-link (&key (href :as string) (hx-select :as string) (active :as boolean) (select-colours :as string)) (defcomp ~navigation/mobile-all-link (&key (href :as string) (hx-select :as string) (active :as boolean) (select-colours :as string))
(a :role "option" :href href :sx-get href :sx-target "#main-panel" (a :role "option" :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:aria-selected (if active "true" "false") :aria-selected (if active "true" "false")
:class (str "block rounded-lg px-3 py-3 text-base hover:bg-stone-50 " select-colours) :class (str "block rounded-lg px-3 py-3 text-base hover:bg-stone-50 " select-colours)
(div :class "prose prose-stone max-w-none" "All"))) (div :class "prose prose-stone max-w-none" "All")))
(defcomp ~market-mobile-chevron () (defcomp ~navigation/mobile-chevron ()
(svg :class "w-4 h-4 shrink-0 transition-transform group-open/cat:rotate-180" (svg :class "w-4 h-4 shrink-0 transition-transform group-open/cat:rotate-180"
:viewBox "0 0 20 20" :fill "currentColor" :viewBox "0 0 20 20" :fill "currentColor"
(path :fill-rule "evenodd" :clip-rule "evenodd" (path :fill-rule "evenodd" :clip-rule "evenodd"
:d "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"))) :d "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z")))
(defcomp ~market-mobile-cat-summary (&key (bg-cls :as string) (href :as string) (hx-select :as string) (select-colours :as string) (cat-name :as string) (count-label :as string) (count-str :as string) (chevron :as list)) (defcomp ~navigation/mobile-cat-summary (&key (bg-cls :as string) (href :as string) (hx-select :as string) (select-colours :as string) (cat-name :as string) (count-label :as string) (count-str :as string) (chevron :as list))
(summary :class (str "flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50" bg-cls) (summary :class (str "flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50" bg-cls)
(a :href href :sx-get href :sx-target "#main-panel" (a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
@@ -37,7 +37,7 @@
(div :aria-label count-label count-str)) (div :aria-label count-label count-str))
chevron)) chevron))
(defcomp ~market-mobile-sub-link (&key (select-colours :as string) (active :as boolean) (href :as string) (hx-select :as string) (label :as string) (count-label :as string) (count-str :as string)) (defcomp ~navigation/mobile-sub-link (&key (select-colours :as string) (active :as boolean) (href :as string) (hx-select :as string) (label :as string) (count-label :as string) (count-str :as string))
(a :class (str "snap-start px-2 py-3 rounded " select-colours " flex flex-row gap-2") (a :class (str "snap-start px-2 py-3 rounded " select-colours " flex flex-row gap-2")
:aria-selected (if active "true" "false") :aria-selected (if active "true" "false")
:href href :sx-get href :sx-target "#main-panel" :href href :sx-get href :sx-target "#main-panel"
@@ -45,20 +45,20 @@
(div label) (div label)
(div :aria-label count-label count-str))) (div :aria-label count-label count-str)))
(defcomp ~market-mobile-subs-panel (&key (links :as list)) (defcomp ~navigation/mobile-subs-panel (&key (links :as list))
(div :class "pb-3 pl-2" (div :class "pb-3 pl-2"
(div :data-peek-viewport "" :data-peek-size-px "18" :data-peek-edge "bottom" :data-peek-mask "true" :class "m-2 bg-stone-100" (div :data-peek-viewport "" :data-peek-size-px "18" :data-peek-edge "bottom" :data-peek-mask "true" :class "m-2 bg-stone-100"
(div :data-peek-inner "" :class "grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" :aria-label "Subcategories" (div :data-peek-inner "" :class "grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" :aria-label "Subcategories"
links)))) links))))
(defcomp ~market-mobile-view-all (&key (href :as string) (hx-select :as string)) (defcomp ~navigation/mobile-view-all (&key (href :as string) (hx-select :as string))
(div :class "pb-3 pl-2" (div :class "pb-3 pl-2"
(a :class "px-2 py-1 rounded hover:bg-stone-100 block" (a :class "px-2 py-1 rounded hover:bg-stone-100 block"
:href href :sx-get href :sx-target "#main-panel" :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true" :sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
"View all"))) "View all")))
(defcomp ~market-mobile-cat-details (&key (open :as boolean) (summary :as list) (subs :as list)) (defcomp ~navigation/mobile-cat-details (&key (open :as boolean) (summary :as list) (subs :as list))
(details :class "group/cat py-1" :open open (details :class "group/cat py-1" :open open
summary subs)) summary subs))
@@ -67,25 +67,25 @@
;; Composition: mobile nav panel from pre-computed category data ;; Composition: mobile nav panel from pre-computed category data
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~market-mobile-nav-from-data (&key (categories :as list) (all-href :as string) (all-active :as boolean) (hx-select :as string) (select-colours :as string)) (defcomp ~navigation/mobile-nav-from-data (&key (categories :as list) (all-href :as string) (all-active :as boolean) (hx-select :as string) (select-colours :as string))
(~market-mobile-nav-wrapper :items (~navigation/mobile-nav-wrapper :items
(<> (<>
(~market-mobile-all-link :href all-href :hx-select hx-select (~navigation/mobile-all-link :href all-href :hx-select hx-select
:active all-active :select-colours select-colours) :active all-active :select-colours select-colours)
(map (lambda (cat) (map (lambda (cat)
(~market-mobile-cat-details (~navigation/mobile-cat-details
:open (get cat "active") :open (get cat "active")
:summary (~market-mobile-cat-summary :summary (~navigation/mobile-cat-summary
:bg-cls (if (get cat "active") " bg-stone-900 text-white hover:bg-stone-900" "") :bg-cls (if (get cat "active") " bg-stone-900 text-white hover:bg-stone-900" "")
:href (get cat "href") :hx-select hx-select :href (get cat "href") :hx-select hx-select
:select-colours select-colours :cat-name (get cat "name") :select-colours select-colours :cat-name (get cat "name")
:count-label (str (get cat "count") " products") :count-label (str (get cat "count") " products")
:count-str (str (get cat "count")) :count-str (str (get cat "count"))
:chevron (~market-mobile-chevron)) :chevron (~navigation/mobile-chevron))
:subs (if (get cat "subs") :subs (if (get cat "subs")
(~market-mobile-subs-panel :links (~navigation/mobile-subs-panel :links
(<> (map (lambda (sub) (<> (map (lambda (sub)
(~market-mobile-sub-link (~navigation/mobile-sub-link
:select-colours select-colours :select-colours select-colours
:active (get sub "active") :active (get sub "active")
:href (get sub "href") :hx-select hx-select :href (get sub "href") :hx-select hx-select
@@ -93,5 +93,5 @@
:count-label (str (get sub "count") " products") :count-label (str (get sub "count") " products")
:count-str (str (get sub "count")))) :count-str (str (get sub "count"))))
(get cat "subs")))) (get cat "subs"))))
(~market-mobile-view-all :href (get cat "href") :hx-select hx-select)))) (~navigation/mobile-view-all :href (get cat "href") :hx-select hx-select))))
categories)))) categories))))

View File

@@ -1,36 +1,36 @@
;; Market price display components ;; Market price display components
(defcomp ~market-price-special (&key (price :as string)) (defcomp ~prices/special (&key (price :as string))
(div :class "text-lg font-semibold text-emerald-700" price)) (div :class "text-lg font-semibold text-emerald-700" price))
(defcomp ~market-price-regular-strike (&key (price :as string)) (defcomp ~prices/regular-strike (&key (price :as string))
(div :class "text-sm line-through text-stone-500" price)) (div :class "text-sm line-through text-stone-500" price))
(defcomp ~market-price-regular (&key (price :as string)) (defcomp ~prices/regular (&key (price :as string))
(div :class "mt-1 text-lg font-semibold" price)) (div :class "mt-1 text-lg font-semibold" price))
(defcomp ~market-price-line (&key (inner :as list)) (defcomp ~prices/line (&key (inner :as list))
(div :class "mt-1 flex items-baseline gap-2 justify-center" inner)) (div :class "mt-1 flex items-baseline gap-2 justify-center" inner))
(defcomp ~market-header-price-special-label () (defcomp ~prices/header-price-special-label ()
(div :class "text-md font-bold text-emerald-700" "Special price")) (div :class "text-md font-bold text-emerald-700" "Special price"))
(defcomp ~market-header-price-special (&key (price :as string)) (defcomp ~prices/header-price-special (&key (price :as string))
(div :class "text-xl font-semibold text-emerald-700" price)) (div :class "text-xl font-semibold text-emerald-700" price))
(defcomp ~market-header-price-strike (&key (price :as string)) (defcomp ~prices/header-price-strike (&key (price :as string))
(div :class "text-base text-md line-through text-stone-500" price)) (div :class "text-base text-md line-through text-stone-500" price))
(defcomp ~market-header-price-regular-label () (defcomp ~prices/header-price-regular-label ()
(div :class "hidden md:block text-xl font-bold" "Our price")) (div :class "hidden md:block text-xl font-bold" "Our price"))
(defcomp ~market-header-price-regular (&key (price :as string)) (defcomp ~prices/header-price-regular (&key (price :as string))
(div :class "text-xl font-semibold" price)) (div :class "text-xl font-semibold" price))
(defcomp ~market-header-rrp (&key (rrp :as string)) (defcomp ~prices/header-rrp (&key (rrp :as string))
(div :class "text-base text-stone-400" (span "rrp:") " " (span rrp))) (div :class "text-base text-stone-400" (span "rrp:") " " (span rrp)))
(defcomp ~market-prices-row (&key (inner :as list)) (defcomp ~prices/row (&key (inner :as list))
(div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" inner)) (div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" inner))
@@ -38,31 +38,31 @@
;; Composition: prices header + cart button from data ;; Composition: prices header + cart button from data
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
(defcomp ~market-prices-header-from-data (&key (cart-id :as string) (cart-action :as string) (csrf :as string) (quantity :as number?) (defcomp ~prices/header-from-data (&key (cart-id :as string) (cart-action :as string) (csrf :as string) (quantity :as number?)
(cart-href :as string) (cart-href :as string)
(sp-val :as number?) (sp-str :as string?) (rp-val :as number?) (rp-str :as string?) (rrp-str :as string?)) (sp-val :as number?) (sp-str :as string?) (rp-val :as number?) (rp-str :as string?) (rrp-str :as string?))
(~market-prices-row :inner (~prices/row :inner
(<> (<>
(if quantity (if quantity
(~market-cart-add-quantity :cart-id cart-id :action cart-action :csrf csrf (~cart/add-quantity :cart-id cart-id :action cart-action :csrf csrf
:minus-val (str (- quantity 1)) :plus-val (str (+ quantity 1)) :minus-val (str (- quantity 1)) :plus-val (str (+ quantity 1))
:quantity (str quantity) :cart-href cart-href) :quantity (str quantity) :cart-href cart-href)
(~market-cart-add-empty :cart-id cart-id :action cart-action :csrf csrf)) (~cart/add-empty :cart-id cart-id :action cart-action :csrf csrf))
(when sp-val (when sp-val
(<> (~market-header-price-special-label) (<> (~prices/header-price-special-label)
(~market-header-price-special :price sp-str) (~prices/header-price-special :price sp-str)
(when rp-val (~market-header-price-strike :price rp-str)))) (when rp-val (~prices/header-price-strike :price rp-str))))
(when (and (not sp-val) rp-val) (when (and (not sp-val) rp-val)
(<> (~market-header-price-regular-label) (<> (~prices/header-price-regular-label)
(~market-header-price-regular :price rp-str))) (~prices/header-price-regular :price rp-str)))
(when rrp-str (~market-header-rrp :rrp rrp-str))))) (when rrp-str (~prices/header-rrp :rrp rrp-str)))))
;; Card price line from data (used in product cards) ;; Card price line from data (used in product cards)
(defcomp ~market-card-price-from-data (&key (sp-val :as number?) (sp-str :as string?) (rp-val :as number?) (rp-str :as string?)) (defcomp ~prices/card-price-from-data (&key (sp-val :as number?) (sp-str :as string?) (rp-val :as number?) (rp-str :as string?))
(~market-price-line :inner (~prices/line :inner
(<> (<>
(when sp-val (when sp-val
(<> (~market-price-special :price sp-str) (<> (~prices/special :price sp-str)
(when rp-val (~market-price-regular-strike :price rp-str)))) (when rp-val (~prices/regular-strike :price rp-str))))
(when (and (not sp-val) rp-val) (when (and (not sp-val) rp-val)
(~market-price-regular :price rp-str))))) (~prices/regular :price rp-str)))))

View File

@@ -13,10 +13,10 @@
:layout :root :layout :root
:data (all-markets-data) :data (all-markets-data)
:content (if no-markets :content (if no-markets
(~empty-state :icon "fa fa-store" :message "No markets available" (~shared:misc/empty-state :icon "fa fa-store" :message "No markets available"
:cls "px-3 py-12 text-center text-stone-400") :cls "px-3 py-12 text-center text-stone-400")
(~market-markets-grid (~grids/markets-grid
:cards (~market-cards-content :cards (~cards/content
:markets market-data :page market-page :markets market-data :page market-page
:has-more has-more :next-url next-url)))) :has-more has-more :next-url next-url))))
@@ -26,10 +26,10 @@
:layout :post :layout :post
:data (page-markets-data) :data (page-markets-data)
:content (if no-markets :content (if no-markets
(~empty-state :message "No markets for this page" (~shared:misc/empty-state :message "No markets for this page"
:cls "px-3 py-12 text-center text-stone-400") :cls "px-3 py-12 text-center text-stone-400")
(~market-markets-grid (~grids/markets-grid
:cards (~market-cards-content :cards (~cards/content
:markets market-data :page market-page :markets market-data :page market-page
:has-more has-more :next-url next-url)))) :has-more has-more :next-url next-url))))
@@ -38,24 +38,24 @@
:auth :admin :auth :admin
:layout (:post-admin :selected "markets") :layout (:post-admin :selected "markets")
:data (page-admin-data) :data (page-admin-data)
:content (~market-admin-content-wrap :content (~grids/admin-content-wrap
:inner (~crud-panel :inner (~shared:misc/crud-panel
:list-id "markets-list" :list-id "markets-list"
:form (when can-create :form (when can-create
(~crud-create-form (~shared:misc/crud-create-form
:create-url create-url :csrf csrf :create-url create-url :csrf csrf
:errors-id "market-create-errors" :list-id "markets-list" :errors-id "market-create-errors" :list-id "markets-list"
:placeholder "e.g. Suma, Craft Fair" :btn-label "Add market")) :placeholder "e.g. Suma, Craft Fair" :btn-label "Add market"))
:list (if admin-markets :list (if admin-markets
(<> (map (fn (m) (<> (map (fn (m)
(~crud-item (~shared:misc/crud-item
:href (get m "href") :name (get m "name") :slug (get m "slug") :href (get m "href") :name (get m "name") :slug (get m "slug")
:del-url (get m "del-url") :csrf-hdr (get m "csrf-hdr") :del-url (get m "del-url") :csrf-hdr (get m "csrf-hdr")
:list-id "markets-list" :list-id "markets-list"
:confirm-title "Delete market?" :confirm-title "Delete market?"
:confirm-text "Products will be hidden (soft delete)")) :confirm-text "Products will be hidden (soft delete)"))
admin-markets)) admin-markets))
(~empty-state (~shared:misc/empty-state
:message "No markets yet. Create one above." :message "No markets yet. Create one above."
:cls "text-gray-500 mt-4"))))) :cls "text-gray-500 mt-4")))))
@@ -64,7 +64,7 @@
:auth :public :auth :public
:layout :market :layout :market
:data (market-home-data) :data (market-home-data)
:content (~market-landing-from-data :content (~cards/landing-from-data
:excerpt excerpt :feature-image feature-image :html html)) :excerpt excerpt :feature-image feature-image :html html))
(defpage market-admin (defpage market-admin

View File

@@ -30,7 +30,7 @@ async def render_browse_page(ctx: dict) -> str:
content = sx_call("market-product-grid", cards=SxExpr(_product_cards_sx(ctx))) content = sx_call("market-product-grid", cards=SxExpr(_product_cards_sx(ctx)))
from shared.sx.helpers import render_to_sx_with_env from shared.sx.helpers import render_to_sx_with_env
hdr = await render_to_sx_with_env("market-browse-layout-full", {}) hdr = await render_to_sx_with_env("layouts/browse-layout-full", {})
menu = _mobile_nav_panel_sx(ctx) menu = _mobile_nav_panel_sx(ctx)
filter_sx = await _mobile_filter_summary_sx(ctx) filter_sx = await _mobile_filter_summary_sx(ctx)
aside_sx = await _desktop_filter_sx(ctx) aside_sx = await _desktop_filter_sx(ctx)
@@ -68,7 +68,7 @@ async def render_product_page(ctx: dict, d: dict) -> str:
meta = _product_meta_sx(d, ctx) meta = _product_meta_sx(d, ctx)
from shared.sx.helpers import render_to_sx_with_env from shared.sx.helpers import render_to_sx_with_env
hdr = await render_to_sx_with_env("market-product-layout-full", {}, hdr = await render_to_sx_with_env("layouts/product-layout-full", {},
post_header=await _post_header_sx(ctx), post_header=await _post_header_sx(ctx),
market_header=_market_header_sx(ctx), market_header=_market_header_sx(ctx),
product_header=_product_header_sx(ctx, d)) product_header=_product_header_sx(ctx, d))
@@ -96,7 +96,7 @@ async def render_product_admin_page(ctx: dict, d: dict) -> str:
content = _product_detail_sx(d, ctx) content = _product_detail_sx(d, ctx)
from shared.sx.helpers import render_to_sx_with_env from shared.sx.helpers import render_to_sx_with_env
hdr = await render_to_sx_with_env("market-product-admin-layout-full", {}, hdr = await render_to_sx_with_env("layouts/product-admin-layout-full", {},
post_header=await _post_header_sx(ctx), post_header=await _post_header_sx(ctx),
market_header=_market_header_sx(ctx), market_header=_market_header_sx(ctx),
product_header=_product_header_sx(ctx, d), product_header=_product_header_sx(ctx, d),

View File

@@ -1,6 +1,6 @@
;; Checkout return page components ;; Checkout return page components
(defcomp ~checkout-return-header (&key (status :as string)) (defcomp ~checkout/return-header (&key (status :as string))
(header :class "mb-1 sm:mb-2 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4" (header :class "mb-1 sm:mb-2 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"
(div :class "space-y-1" (div :class "space-y-1"
(h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight"
@@ -16,23 +16,23 @@
((= status "missing") "We couldn\u2019t find that order \u2013 it may have expired or never been created.") ((= status "missing") "We couldn\u2019t find that order \u2013 it may have expired or never been created.")
(t "We\u2019re still waiting for a final confirmation from SumUp.")))))) (t "We\u2019re still waiting for a final confirmation from SumUp."))))))
(defcomp ~checkout-return-missing () (defcomp ~checkout/return-missing ()
(div :class "max-w-full px-1 py-1" (div :class "max-w-full px-1 py-1"
(div :class "rounded-2xl border border-dashed border-rose-300 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-800" (div :class "rounded-2xl border border-dashed border-rose-300 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-800"
"We couldn\u2019t find that order. If you reached this page from an old link, please start a new order."))) "We couldn\u2019t find that order. If you reached this page from an old link, please start a new order.")))
(defcomp ~checkout-return-failed (&key (order-id :as string)) (defcomp ~checkout/return-failed (&key (order-id :as string))
(div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2" (div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"
(p :class "font-medium" "Your payment was not completed.") (p :class "font-medium" "Your payment was not completed.")
(p "You can go back to your cart and try checkout again. If the problem persists, please contact us and mention order " (p "You can go back to your cart and try checkout again. If the problem persists, please contact us and mention order "
(span :class "font-mono" (str "#" order-id)) "."))) (span :class "font-mono" (str "#" order-id)) ".")))
(defcomp ~checkout-return-paid () (defcomp ~checkout/return-paid ()
(div :class "rounded-2xl border border-emerald-200 bg-emerald-50/80 p-4 sm:p-6 text-sm text-emerald-900 space-y-2" (div :class "rounded-2xl border border-emerald-200 bg-emerald-50/80 p-4 sm:p-6 text-sm text-emerald-900 space-y-2"
(p :class "font-medium" "All done!") (p :class "font-medium" "All done!")
(p "We\u2019ll start processing your order shortly."))) (p "We\u2019ll start processing your order shortly.")))
(defcomp ~checkout-return-ticket (&key (name :as string) (pill :as string) (state :as string) (type-name :as string?) (date-str :as string) (code :as string) (price :as string)) (defcomp ~checkout/return-ticket (&key (name :as string) (pill :as string) (state :as string) (type-name :as string?) (date-str :as string) (code :as string) (price :as string))
(li :class "px-4 py-3 flex items-start justify-between text-sm" (li :class "px-4 py-3 flex items-start justify-between text-sm"
(div (div
(div :class "font-medium flex items-center gap-2" (div :class "font-medium flex items-center gap-2"
@@ -42,23 +42,23 @@
(div :class "text-xs text-stone-400 font-mono mt-0.5" code)) (div :class "text-xs text-stone-400 font-mono mt-0.5" code))
(div :class "ml-4 font-medium" price))) (div :class "ml-4 font-medium" price)))
(defcomp ~checkout-return-tickets (&key items) (defcomp ~checkout/return-tickets (&key items)
(section :class "mt-6 space-y-3" (section :class "mt-6 space-y-3"
(h2 :class "text-base sm:text-lg font-semibold" "Event tickets in this order") (h2 :class "text-base sm:text-lg font-semibold" "Event tickets in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items))) (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
;; Data-driven ticket items (replaces Python loop) ;; Data-driven ticket items (replaces Python loop)
(defcomp ~checkout-return-tickets-from-data (&key (tickets :as list)) (defcomp ~checkout/return-tickets-from-data (&key (tickets :as list))
(~checkout-return-tickets (~checkout/return-tickets
:items (<> (map (lambda (tk) :items (<> (map (lambda (tk)
(~checkout-return-ticket (~checkout/return-ticket
:name (get tk "name") :pill (get tk "pill") :name (get tk "name") :pill (get tk "pill")
:state (get tk "state") :type-name (get tk "type_name") :state (get tk "state") :type-name (get tk "type_name")
:date-str (get tk "date_str") :code (get tk "code") :date-str (get tk "date_str") :code (get tk "code")
:price (get tk "price"))) :price (get tk "price")))
(or tickets (list)))))) (or tickets (list))))))
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message) (defcomp ~checkout/return-content (&key summary items calendar tickets status-message)
(div :class "max-w-full px-1 py-1" (div :class "max-w-full px-1 py-1"
(when summary (when summary
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2" summary)) (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2" summary))

View File

@@ -4,6 +4,6 @@
;; Renders the "orders" link for the account dashboard nav. ;; Renders the "orders" link for the account dashboard nav.
(defhandler account-nav-item (&key) (defhandler account-nav-item (&key)
(~account-nav-item (~shared:fragments/account-nav-item
:href (app-url "orders" "/") :href (app-url "orders" "/")
:label "orders")) :label "orders"))

View File

@@ -3,40 +3,40 @@
;; --- orders layout: root + auth + orders rows --- ;; --- orders layout: root + auth + orders rows ---
(defcomp ~orders-layout-full (&key (list-url :as string)) (defcomp ~layouts/full (&key (list-url :as string))
(<> (~root-header-auto) (<> (~root-header-auto)
(~header-child-sx (~shared:layout/header-child-sx
:inner (<> (~auth-header-row-auto) :inner (<> (~auth-header-row-auto)
(~orders-header-row :list-url (or list-url "/")))))) (~shared:auth/orders-header-row :list-url (or list-url "/"))))))
(defcomp ~orders-layout-oob (&key (list-url :as string)) (defcomp ~layouts/oob (&key (list-url :as string))
(<> (~auth-header-row-auto true) (<> (~auth-header-row-auto true)
(~oob-header-sx (~shared:layout/oob-header-sx
:parent-id "auth-header-child" :parent-id "auth-header-child"
:row (~orders-header-row :list-url (or list-url "/"))) :row (~shared:auth/orders-header-row :list-url (or list-url "/")))
(~root-header-auto true))) (~root-header-auto true)))
(defcomp ~orders-layout-mobile () (defcomp ~layouts/mobile ()
(~root-mobile-auto)) (~root-mobile-auto))
;; --- order-detail layout: root + auth + orders + order rows --- ;; --- order-detail layout: root + auth + orders + order rows ---
(defcomp ~order-detail-layout-full (&key (list-url :as string) (detail-url :as string)) (defcomp ~layouts/order-detail-layout-full (&key (list-url :as string) (detail-url :as string))
(<> (~root-header-auto) (<> (~root-header-auto)
(~order-detail-header-stack (~shared:orders/detail-header-stack
:auth (~auth-header-row-auto) :auth (~auth-header-row-auto)
:orders (~orders-header-row :list-url (or list-url "/")) :orders (~shared:auth/orders-header-row :list-url (or list-url "/"))
:order (~menu-row-sx :id "order-row" :level 3 :colour "sky" :order (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href (or detail-url "/") :link-label "Order" :link-href (or detail-url "/") :link-label "Order"
:icon "fa fa-gbp")))) :icon "fa fa-gbp"))))
(defcomp ~order-detail-layout-oob (&key (detail-url :as string)) (defcomp ~layouts/order-detail-layout-oob (&key (detail-url :as string))
(<> (~oob-header-sx (<> (~shared:layout/oob-header-sx
:parent-id "orders-header-child" :parent-id "orders-header-child"
:row (~menu-row-sx :id "order-row" :level 3 :colour "sky" :row (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
:link-href (or detail-url "/") :link-label "Order" :link-href (or detail-url "/") :link-label "Order"
:icon "fa fa-gbp" :oob true)) :icon "fa fa-gbp" :oob true))
(~root-header-auto true))) (~root-header-auto true)))
(defcomp ~order-detail-layout-mobile () (defcomp ~layouts/order-detail-layout-mobile ()
(~root-mobile-auto)) (~root-mobile-auto))

View File

@@ -13,14 +13,14 @@
:page (or (request-arg "page" "1") "1")) :page (or (request-arg "page" "1") "1"))
:layout (:orders :layout (:orders
:list-url (str (route-prefix) (url-for "defpage_orders_list"))) :list-url (str (route-prefix) (url-for "defpage_orders_list")))
:filter (~order-list-header :filter (~shared:orders/list-header
:search-mobile (~search-mobile :search-mobile (~shared:controls/search-mobile
:current-local-href "/" :current-local-href "/"
:search (or search "") :search (or search "")
:search-count (or search-count "") :search-count (or search-count "")
:hx-select "#main-panel" :hx-select "#main-panel"
:search-headers-mobile "{\"X-Origin\":\"search-mobile\",\"X-Search\":\"true\"}")) :search-headers-mobile "{\"X-Origin\":\"search-mobile\",\"X-Search\":\"true\"}"))
:aside (~search-desktop :aside (~shared:controls/search-desktop
:current-local-href "/" :current-local-href "/"
:search (or search "") :search (or search "")
:search-count (or search-count "") :search-count (or search-count "")
@@ -30,7 +30,7 @@
(detail-url-raw (str pfx (url-for "defpage_order_detail" :order-id 0))) (detail-url-raw (str pfx (url-for "defpage_order_detail" :order-id 0)))
(detail-prefix (slice detail-url-raw 0 (- (len detail-url-raw) 2))) (detail-prefix (slice detail-url-raw 0 (- (len detail-url-raw) 2)))
(rows-url (str pfx (url-for "orders.orders_rows")))) (rows-url (str pfx (url-for "orders.orders_rows"))))
(~orders-list-content (~shared:orders/list-content
:orders orders :orders orders
:page page :page page
:total-pages total-pages :total-pages total-pages
@@ -49,12 +49,12 @@
:list-url (str (route-prefix) (url-for "defpage_orders_list")) :list-url (str (route-prefix) (url-for "defpage_orders_list"))
:detail-url (str (route-prefix) (url-for "defpage_order_detail" :order-id order-id))) :detail-url (str (route-prefix) (url-for "defpage_order_detail" :order-id order-id)))
:filter (let* ((pfx (route-prefix))) :filter (let* ((pfx (route-prefix)))
(~order-detail-filter-content (~shared:orders/detail-filter-content
:order order :order order
:list-url (str pfx (url-for "defpage_orders_list")) :list-url (str pfx (url-for "defpage_orders_list"))
:recheck-url (str pfx (url-for "orders.order.order_recheck" :order-id order-id)) :recheck-url (str pfx (url-for "orders.order.order_recheck" :order-id order-id))
:pay-url (str pfx (url-for "orders.order.order_pay" :order-id order-id)) :pay-url (str pfx (url-for "orders.order.order_pay" :order-id order-id))
:csrf (csrf-token))) :csrf (csrf-token)))
:content (~order-detail-content :content (~shared:orders/detail-content
:order order :order order
:calendar-entries calendar-entries)) :calendar-entries calendar-entries))

View File

@@ -39,7 +39,7 @@
(href (if svc-name (href (if svc-name
(app-url svc-name path) (app-url svc-name path)
path))) path)))
(~relation-nav (~shared:navigation/relation-nav
:href href :href href
:name (or (get child "label") "") :name (or (get child "label") "")
:icon (or (get defn "nav_icon") "") :icon (or (get defn "nav_icon") "")

View File

@@ -62,7 +62,7 @@ def _sx_error_page(errnum: str, message: str, image: str | None = None) -> str:
from shared.sx.page import render_page from shared.sx.page import render_page
return render_page( return render_page(
'(~error-page :title title :message message :image image :asset-url "/static")', '(~shared:pages/error-page :title title :message message :image image :asset-url "/static")',
title=f"{errnum} Error", title=f"{errnum} Error",
message=message, message=message,
image=image, image=image,

View File

@@ -118,6 +118,11 @@ def create_base_app(
setup_jinja(app) setup_jinja(app)
setup_sx_bridge(app) setup_sx_bridge(app)
load_shared_components() load_shared_components()
# Finalize shared components (deps, hash) now — service apps that call
# load_service_components() will re-finalize after loading their own.
from shared.sx.jinja_bridge import _rebuild_closures, _finalize_if_needed
_rebuild_closures()
_finalize_if_needed()
load_relation_registry() load_relation_registry()
# Load defquery/defaction definitions from {service}/queries.sx and actions.sx # Load defquery/defaction definitions from {service}/queries.sx and actions.sx

File diff suppressed because it is too large Load Diff

View File

@@ -13,14 +13,18 @@ from .jinja_bridge import load_sx_dir, register_reload_callback, watch_sx_dir
def load_shared_components() -> None: def load_shared_components() -> None:
"""Register all shared s-expression components.""" """Register all shared s-expression components.
Defers finalization (deps/hash) so the calling app can load service
components before the single finalize pass.
"""
# Load SX libraries first — reader macros (#z3 etc.) must resolve # Load SX libraries first — reader macros (#z3 etc.) must resolve
# before any .sx file that uses them is parsed # before any .sx file that uses them is parsed
_load_sx_libraries() _load_sx_libraries()
register_reload_callback(_load_sx_libraries) register_reload_callback(_load_sx_libraries)
templates_dir = os.path.join(os.path.dirname(__file__), "templates") templates_dir = os.path.join(os.path.dirname(__file__), "templates")
load_sx_dir(templates_dir) load_sx_dir(templates_dir, _finalize=False)
watch_sx_dir(templates_dir) watch_sx_dir(templates_dir)
@@ -32,4 +36,4 @@ def _load_sx_libraries() -> None:
path = os.path.join(ref_dir, name) path = os.path.join(ref_dir, name)
if os.path.exists(path): if os.path.exists(path):
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
register_components(f.read()) register_components(f.read(), _defer_postprocess=True)

View File

@@ -126,7 +126,7 @@ def _compute_all_io_refs_fallback(
def _scan_components_from_sx_fallback(source: str) -> set[str]: def _scan_components_from_sx_fallback(source: str) -> set[str]:
import re import re
return {f"~{m}" for m in re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-]*)', source)} return {f"~{m}" for m in re.findall(r'\(~([a-zA-Z_][a-zA-Z0-9_\-:/]*)', source)}
def _components_needed_fallback(page_sx: str, env: dict[str, Any]) -> set[str]: def _components_needed_fallback(page_sx: str, env: dict[str, Any]) -> set[str]:
@@ -170,7 +170,7 @@ def compute_all_deps(env: dict[str, Any]) -> None:
def scan_components_from_sx(source: str) -> set[str]: def scan_components_from_sx(source: str) -> set[str]:
"""Extract component names referenced in SX source text. """Extract component names referenced in SX source text.
Returns names with ~ prefix, e.g. {"~card", "~nav-link"}. Returns names with ~ prefix, e.g. {"~card", "~shared:layout/nav-link"}.
""" """
if _use_ref(): if _use_ref():
from .ref.sx_ref import scan_components_from_source as _ref_sc from .ref.sx_ref import scan_components_from_source as _ref_sc

View File

@@ -24,11 +24,25 @@ import logging
import os import os
from typing import Any, Callable, Awaitable from typing import Any, Callable, Awaitable
from werkzeug.routing import BaseConverter
from .types import HandlerDef from .types import HandlerDef
logger = logging.getLogger("sx.handlers") logger = logging.getLogger("sx.handlers")
class SxAtomConverter(BaseConverter):
"""URL converter for SX atoms inside expression URLs.
Matches a single atom — stops at dots, parens, slashes, and query chars.
Use as ``<sx:param>`` in route patterns like::
/(geography.(hypermedia.(reference.(api.(item.<sx:item_id>)))))
"""
regex = r"[^./)(?&= ]+"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Registry — service → handler-name → HandlerDef # Registry — service → handler-name → HandlerDef
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -229,6 +243,10 @@ def register_route_handlers(app_or_bp: Any, service_name: str) -> int:
from quart import Response, request from quart import Response, request
from shared.browser.app.csrf import csrf_exempt from shared.browser.app.csrf import csrf_exempt
# Register SX atom converter for expression URL parameters
if hasattr(app_or_bp, 'url_map'):
app_or_bp.url_map.converters.setdefault('sx', SxAtomConverter)
handlers = get_all_handlers(service_name) handlers = get_all_handlers(service_name)
count = 0 count = 0

View File

@@ -69,7 +69,7 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
rights = ctx.get("rights") or {} rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else "" settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
return await _render_to_sx("header-row-sx", return await _render_to_sx("shared:layout/header-row-sx",
cart_mini=_as_sx(ctx.get("cart_mini")), cart_mini=_as_sx(ctx.get("cart_mini")),
blog_url=call_url(ctx, "blog_url", ""), blog_url=call_url(ctx, "blog_url", ""),
site_title=ctx.get("base_title", ""), site_title=ctx.get("base_title", ""),
@@ -90,12 +90,12 @@ def mobile_menu_sx(*sections: str) -> SxExpr:
async def mobile_root_nav_sx(ctx: dict) -> str: async def mobile_root_nav_sx(ctx: dict) -> str:
"""Root-level mobile nav via ~mobile-root-nav component.""" """Root-level mobile nav via ~shared:layout/mobile-root-nav component."""
nav_tree = ctx.get("nav_tree") or "" nav_tree = ctx.get("nav_tree") or ""
auth_menu = ctx.get("auth_menu") or "" auth_menu = ctx.get("auth_menu") or ""
if not nav_tree and not auth_menu: if not nav_tree and not auth_menu:
return "" return ""
return await _render_to_sx("mobile-root-nav", return await _render_to_sx("shared:layout/mobile-root-nav",
nav_tree=_as_sx(nav_tree), nav_tree=_as_sx(nav_tree),
auth_menu=_as_sx(auth_menu), auth_menu=_as_sx(auth_menu),
) )
@@ -116,13 +116,13 @@ async def _post_nav_items_sx(ctx: dict) -> SxExpr:
page_cart_count = ctx.get("page_cart_count", 0) page_cart_count = ctx.get("page_cart_count", 0)
if page_cart_count and page_cart_count > 0: if page_cart_count and page_cart_count > 0:
cart_href = call_url(ctx, "cart_url", f"/{slug}/") cart_href = call_url(ctx, "cart_url", f"/{slug}/")
parts.append(await _render_to_sx("page-cart-badge", href=cart_href, parts.append(await _render_to_sx("shared:layout/page-cart-badge", href=cart_href,
count=str(page_cart_count))) count=str(page_cart_count)))
container_nav = str(ctx.get("container_nav") or "").strip() container_nav = str(ctx.get("container_nav") or "").strip()
# Skip empty fragment wrappers like "(<> )" # Skip empty fragment wrappers like "(<> )"
if container_nav and container_nav.replace("(<>", "").replace(")", "").strip(): if container_nav and container_nav.replace("(<>", "").replace(")", "").strip():
parts.append(await _render_to_sx("container-nav-wrapper", parts.append(await _render_to_sx("shared:layout/container-nav-wrapper",
content=SxExpr(container_nav))) content=SxExpr(container_nav)))
# Admin cog # Admin cog
@@ -134,7 +134,7 @@ async def _post_nav_items_sx(ctx: dict) -> SxExpr:
from quart import request from quart import request
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/") admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path is_admin_page = ctx.get("is_admin_section") or "/admin" in request.path
admin_nav = await _render_to_sx("admin-cog-button", admin_nav = await _render_to_sx("shared:layout/admin-cog-button",
href=admin_href, href=admin_href,
is_admin_page=is_admin_page or None) is_admin_page=is_admin_page or None)
if admin_nav: if admin_nav:
@@ -164,7 +164,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
continue continue
href = url_fn(path) href = url_fn(path)
is_sel = label == selected is_sel = label == selected
parts.append(await _render_to_sx("nav-link", href=href, label=label, parts.append(await _render_to_sx("shared:layout/nav-link", href=href, label=label,
select_colours=select_colours, select_colours=select_colours,
is_selected=is_sel or None)) is_selected=is_sel or None))
return _sx_fragment(*parts) if parts else SxExpr("") return _sx_fragment(*parts) if parts else SxExpr("")
@@ -182,7 +182,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
post = ctx.get("post") or {} post = ctx.get("post") or {}
slug = post.get("slug", "") slug = post.get("slug", "")
title = (post.get("title") or slug)[:40] title = (post.get("title") or slug)[:40]
return await _render_to_sx("mobile-menu-section", return await _render_to_sx("shared:layout/mobile-menu-section",
label=title, label=title,
href=call_url(ctx, "blog_url", f"/{slug}/"), href=call_url(ctx, "blog_url", f"/{slug}/"),
level=1, level=1,
@@ -193,7 +193,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
async def search_mobile_sx(ctx: dict) -> str: async def search_mobile_sx(ctx: dict) -> str:
"""Build mobile search input as sx wire format.""" """Build mobile search input as sx wire format."""
return await _render_to_sx("search-mobile", return await _render_to_sx("shared:controls/search-mobile",
current_local_href=ctx.get("current_local_href", "/"), current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""), search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""), search_count=ctx.get("search_count", ""),
@@ -204,7 +204,7 @@ async def search_mobile_sx(ctx: dict) -> str:
async def search_desktop_sx(ctx: dict) -> str: async def search_desktop_sx(ctx: dict) -> str:
"""Build desktop search input as sx wire format.""" """Build desktop search input as sx wire format."""
return await _render_to_sx("search-desktop", return await _render_to_sx("shared:controls/search-desktop",
current_local_href=ctx.get("current_local_href", "/"), current_local_href=ctx.get("current_local_href", "/"),
search=ctx.get("search", ""), search=ctx.get("search", ""),
search_count=ctx.get("search_count", ""), search_count=ctx.get("search_count", ""),
@@ -222,11 +222,11 @@ async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> st
title = (post.get("title") or "")[:160] title = (post.get("title") or "")[:160]
feature_image = post.get("feature_image") feature_image = post.get("feature_image")
label_sx = await _render_to_sx("post-label", feature_image=feature_image, title=title) label_sx = await _render_to_sx("shared:layout/post-label", feature_image=feature_image, title=title)
nav_sx = await _post_nav_items_sx(ctx) or None nav_sx = await _post_nav_items_sx(ctx) or None
link_href = call_url(ctx, "blog_url", f"/{slug}/") link_href = call_url(ctx, "blog_url", f"/{slug}/")
return await _render_to_sx("menu-row-sx", return await _render_to_sx("shared:layout/menu-row-sx",
id="post-row", level=1, id="post-row", level=1,
link_href=link_href, link_href=link_href,
link_label_content=label_sx, link_label_content=label_sx,
@@ -241,7 +241,7 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str: selected: str = "", admin_href: str = "") -> str:
"""Post admin header row as sx wire format.""" """Post admin header row as sx wire format."""
# Label # Label
label_sx = await _render_to_sx("post-admin-label", label_sx = await _render_to_sx("shared:layout/post-admin-label",
selected=str(escape(selected)) if selected else None) selected=str(escape(selected)) if selected else None)
nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None nav_sx = await _post_admin_nav_items_sx(ctx, slug, selected) or None
@@ -250,7 +250,7 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
blog_fn = ctx.get("blog_url") blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/" admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return await _render_to_sx("menu-row-sx", return await _render_to_sx("shared:layout/menu-row-sx",
id="post-admin-row", level=2, id="post-admin-row", level=2,
link_href=admin_href, link_href=admin_href,
link_label_content=label_sx, link_label_content=label_sx,
@@ -263,9 +263,9 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
"""Wrap a header row sx in an OOB swap. """Wrap a header row sx in an OOB swap.
child_id is accepted for call-site compatibility but no longer used — child_id is accepted for call-site compatibility but no longer used —
the child placeholder is created by ~menu-row-sx itself. the child placeholder is created by ~shared:layout/menu-row-sx itself.
""" """
return await _render_to_sx("oob-header-sx", return await _render_to_sx("shared:layout/oob-header-sx",
parent_id=parent_id, parent_id=parent_id,
row=SxExpr(row_sx), row=SxExpr(row_sx),
) )
@@ -273,7 +273,7 @@ async def oob_header_sx(parent_id: str, child_id: str, row_sx: str) -> str:
async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str: async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> str:
"""Wrap inner sx in a header-child div.""" """Wrap inner sx in a header-child div."""
return await _render_to_sx("header-child-sx", return await _render_to_sx("shared:layout/header-child-sx",
id=id, inner=_sx_fragment(inner_sx), id=id, inner=_sx_fragment(inner_sx),
) )
@@ -281,7 +281,7 @@ async def header_child_sx(inner_sx: str, *, id: str = "root-header-child") -> st
async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "", async def oob_page_sx(*, oobs: str = "", filter: str = "", aside: str = "",
content: str = "", menu: str = "") -> str: content: str = "", menu: str = "") -> str:
"""Build OOB response as sx wire format.""" """Build OOB response as sx wire format."""
return await _render_to_sx("oob-sx", return await _render_to_sx("shared:layout/oob-sx",
oobs=_sx_fragment(oobs) if oobs else None, oobs=_sx_fragment(oobs) if oobs else None,
filter=SxExpr(filter) if filter else None, filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None, aside=SxExpr(aside) if aside else None,
@@ -307,7 +307,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
# Auto-generate mobile nav from context when no menu provided # Auto-generate mobile nav from context when no menu provided
if not menu: if not menu:
menu = await mobile_root_nav_sx(ctx) menu = await mobile_root_nav_sx(ctx)
body_sx = await _render_to_sx("app-body", body_sx = await _render_to_sx("shared:layout/app-body",
header_rows=_sx_fragment(header_rows) if header_rows else None, header_rows=_sx_fragment(header_rows) if header_rows else None,
filter=SxExpr(filter) if filter else None, filter=SxExpr(filter) if filter else None,
aside=SxExpr(aside) if aside else None, aside=SxExpr(aside) if aside else None,
@@ -636,7 +636,7 @@ def sx_response(source: str, status: int = 200,
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sx wire-format full page shell # Sx wire-format full page shell
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The page shell is defined as ~sx-page-shell in shared/sx/templates/shell.sx # The page shell is defined as ~shared:shell/sx-page-shell in shared/sx/templates/shell.sx
# and rendered via render_to_html. No HTML string templates in Python. # and rendered via render_to_html. No HTML string templates in Python.
@@ -780,7 +780,7 @@ async def sx_page(ctx: dict, page_sx: str, *,
renders everything client-side. CSS rules are scanned from the sx renders everything client-side. CSS rules are scanned from the sx
source and component defs, then injected as a <style> block. source and component defs, then injected as a <style> block.
The shell is rendered from the ~sx-page-shell SX component The shell is rendered from the ~shared:shell/sx-page-shell SX component
(shared/sx/templates/shell.sx). (shared/sx/templates/shell.sx).
""" """
from .jinja_bridge import components_for_page, css_classes_for_page from .jinja_bridge import components_for_page, css_classes_for_page
@@ -876,7 +876,7 @@ async def sx_page(ctx: dict, page_sx: str, *,
shell_kwargs["init_sx"] = init_sx shell_kwargs["init_sx"] = init_sx
if body_scripts is not None: if body_scripts is not None:
shell_kwargs["body_scripts"] = body_scripts shell_kwargs["body_scripts"] = body_scripts
return await render_to_html("sx-page-shell", **shell_kwargs) return await render_to_html("shared:shell/sx-page-shell", **shell_kwargs)
_SX_STREAMING_RESOLVE = """\ _SX_STREAMING_RESOLVE = """\

View File

@@ -6,7 +6,7 @@ can coexist during incremental migration:
**Jinja → s-expression** (use s-expression components inside Jinja templates):: **Jinja → s-expression** (use s-expression components inside Jinja templates)::
{{ sx('(~link-card :slug "apple" :title "Apple")') | safe }} {{ sx('(~shared:fragments/link-card :slug "apple" :title "Apple")') | safe }}
**S-expression → Jinja** (embed Jinja output inside s-expressions):: **S-expression → Jinja** (embed Jinja output inside s-expressions)::
@@ -22,10 +22,13 @@ from __future__ import annotations
import glob import glob
import hashlib import hashlib
import logging
import os import os
import pickle
import time
from typing import Any from typing import Any
from .types import NIL, Component, Island, Keyword, Macro, Symbol from .types import NIL, Component, Island, Keyword, Lambda, Macro, Symbol
from .parser import parse from .parser import parse
import os as _os import os as _os
if _os.environ.get("SX_USE_REF") == "1": if _os.environ.get("SX_USE_REF") == "1":
@@ -33,6 +36,8 @@ if _os.environ.get("SX_USE_REF") == "1":
else: else:
from .html import render as html_render, _render_component from .html import render as html_render, _render_component
_logger = logging.getLogger("sx.bridge")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Shared component environment # Shared component environment
@@ -97,30 +102,193 @@ def _compute_component_hash() -> None:
_COMPONENT_HASH = "" _COMPONENT_HASH = ""
def load_sx_dir(directory: str) -> None: _CACHE_DIR = os.path.join(os.path.dirname(__file__), ".cache")
def _cache_key_for_dir(directory: str, files: list[str]) -> str:
"""Compute a cache key from sorted file paths + mtimes + sizes."""
parts = []
for fp in files:
st = os.stat(fp)
parts.append(f"{fp}:{st.st_mtime_ns}:{st.st_size}")
return hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16]
def _cache_path(directory: str, key: str) -> str:
"""Return the cache file path for a directory."""
dir_hash = hashlib.sha256(directory.encode()).hexdigest()[:12]
return os.path.join(_CACHE_DIR, f"sx_{dir_hash}_{key}.pkl")
def _try_load_cache(directory: str, files: list[str]) -> bool:
"""Try to restore components from a pickle cache.
Returns True if cache was valid and components were restored.
"""
key = _cache_key_for_dir(directory, files)
path = _cache_path(directory, key)
if not os.path.exists(path):
return False
try:
with open(path, "rb") as f:
cached = pickle.load(f)
_COMPONENT_ENV.update(cached["env"])
_CLIENT_LIBRARY_SOURCES.extend(cached["client_sources"])
_logger.info("Cache hit: %s (%d entries)", directory, len(cached["env"]))
return True
except Exception as e:
_logger.warning("Cache load failed for %s: %s", directory, e)
try:
os.remove(path)
except OSError:
pass
return False
def _save_cache(
directory: str,
files: list[str],
env_entries: dict[str, Any],
client_sources: list[str],
) -> None:
"""Save component env entries to a pickle cache."""
key = _cache_key_for_dir(directory, files)
path = _cache_path(directory, key)
try:
os.makedirs(_CACHE_DIR, exist_ok=True)
# Strip closures before pickling — they reference the global env
# and would bloat/fail the pickle. Closures are rebuilt after load.
stripped = _strip_closures(env_entries)
with open(path, "wb") as f:
pickle.dump({"env": stripped, "client_sources": client_sources}, f,
protocol=pickle.HIGHEST_PROTOCOL)
# Clean stale caches for this directory
dir_hash = hashlib.sha256(directory.encode()).hexdigest()[:12]
prefix = f"sx_{dir_hash}_"
for old in os.listdir(_CACHE_DIR):
if old.startswith(prefix) and old != os.path.basename(path):
try:
os.remove(os.path.join(_CACHE_DIR, old))
except OSError:
pass
except Exception as e:
_logger.warning("Cache save failed for %s: %s", directory, e)
def _strip_closures(env_entries: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of env entries with closures emptied for pickling."""
out: dict[str, Any] = {}
for key, val in env_entries.items():
if isinstance(val, Component):
out[key] = Component(
name=val.name, params=list(val.params),
has_children=val.has_children, body=val.body,
closure={}, css_classes=set(val.css_classes),
deps=set(val.deps), io_refs=set(val.io_refs) if val.io_refs else None,
affinity=val.affinity, param_types=dict(val.param_types) if val.param_types else None,
)
elif isinstance(val, Island):
out[key] = Island(
name=val.name, params=list(val.params),
has_children=val.has_children, body=val.body,
closure={}, css_classes=set(val.css_classes),
deps=set(val.deps), io_refs=set(val.io_refs) if val.io_refs else None,
)
elif isinstance(val, Macro):
out[key] = Macro(
params=list(val.params), rest_param=val.rest_param,
body=val.body, closure={}, name=val.name,
)
elif isinstance(val, Lambda):
out[key] = Lambda(
params=list(val.params), body=val.body,
closure={}, name=val.name,
)
else:
# Basic values (dicts, lists, strings, numbers) — pickle directly
out[key] = val
return out
def _rebuild_closures() -> None:
"""Point all component/lambda closures at the global env.
After cache restore, closures are empty. The evaluator merges
closure + caller-env at call time, and the caller env is always
_COMPONENT_ENV, so this is safe.
"""
for val in _COMPONENT_ENV.values():
if isinstance(val, (Component, Island, Lambda, Macro)):
val.closure = _COMPONENT_ENV
_dirs_from_cache: set[str] = set()
def load_sx_dir(directory: str, *, _finalize: bool = True) -> None:
"""Load all .sx files from a directory and register components. """Load all .sx files from a directory and register components.
Skips boundary.sx — those are parsed separately by the boundary validator. Skips boundary.sx — those are parsed separately by the boundary validator.
Files starting with ``;; @client`` have their source stored for delivery Files starting with ``;; @client`` have their source stored for delivery
to the browser (so ``define`` forms are available client-side). to the browser (so ``define`` forms are available client-side).
Uses a pickle cache keyed by file mtimes — if no files changed,
components are restored from cache without parsing or evaluation.
""" """
for filepath in sorted( t0 = time.monotonic()
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
): files = sorted(
if os.path.basename(filepath) == "boundary.sx": fp for fp in glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
continue if os.path.basename(fp) != "boundary.sx"
)
if not files:
return
# Try cache first
if _try_load_cache(directory, files):
_dirs_from_cache.add(directory)
if _finalize:
_rebuild_closures()
_finalize_if_needed()
t1 = time.monotonic()
_logger.info("Loaded %s from cache in %.1fms", directory, (t1 - t0) * 1000)
return
# Cache miss — full parse + eval
env_before = set(_COMPONENT_ENV.keys())
new_client_sources: list[str] = []
for filepath in files:
with open(filepath, encoding="utf-8") as f: with open(filepath, encoding="utf-8") as f:
source = f.read() source = f.read()
if source.lstrip().startswith(";; @client"): if source.lstrip().startswith(";; @client"):
# Parse and re-serialize to normalize syntax sugar.
# The Python parser accepts ' for quote but the bootstrapped
# client parser uses #' — re-serializing emits (quote x).
from .parser import parse_all, serialize from .parser import parse_all, serialize
exprs = parse_all(source) exprs = parse_all(source)
_CLIENT_LIBRARY_SOURCES.append( normalized = "\n".join(serialize(e) for e in exprs)
"\n".join(serialize(e) for e in exprs) new_client_sources.append(normalized)
) _CLIENT_LIBRARY_SOURCES.append(normalized)
register_components(source) register_components(source, _defer_postprocess=True)
if _finalize:
finalize_components()
# Save cache AFTER finalization so deps/io_refs are included
new_entries = {k: v for k, v in _COMPONENT_ENV.items() if k not in env_before}
_save_cache(directory, files, new_entries, new_client_sources)
t1 = time.monotonic()
_logger.info("Loaded %s (%d files, %d new) in %.1fms",
directory, len(files), len(new_entries), (t1 - t0) * 1000)
def _finalize_if_needed() -> None:
"""Skip heavy deps/io_refs recomputation if all directories were cached.
Cached components already have deps and io_refs populated.
Only the hash needs recomputing (it depends on all components).
"""
_compute_component_hash()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -149,9 +317,7 @@ def watch_sx_dir(directory: str) -> None:
def reload_if_changed() -> None: def reload_if_changed() -> None:
"""Re-read sx files if any have changed on disk. Called per-request in dev.""" """Re-read sx files if any have changed on disk. Called per-request in dev."""
import logging reload_logger = logging.getLogger("sx.reload")
import time
_logger = logging.getLogger("sx.reload")
changed_files = [] changed_files = []
for directory in _watched_dirs: for directory in _watched_dirs:
@@ -164,17 +330,22 @@ def reload_if_changed() -> None:
changed_files.append(fp) changed_files.append(fp)
if changed_files: if changed_files:
for fp in changed_files: for fp in changed_files:
_logger.info("Changed: %s", fp) reload_logger.info("Changed: %s", fp)
t0 = time.monotonic() t0 = time.monotonic()
_COMPONENT_ENV.clear() _COMPONENT_ENV.clear()
_CLIENT_LIBRARY_SOURCES.clear() _CLIENT_LIBRARY_SOURCES.clear()
_dirs_from_cache.clear()
# Reload SX libraries first (e.g. z3.sx) so reader macros resolve # Reload SX libraries first (e.g. z3.sx) so reader macros resolve
for cb in _reload_callbacks: for cb in _reload_callbacks:
cb() cb()
# Load all directories with deferred finalization
for directory in _watched_dirs: for directory in _watched_dirs:
load_sx_dir(directory) load_sx_dir(directory, _finalize=False)
# Finalize once after all directories are loaded
_rebuild_closures()
finalize_components()
t1 = time.monotonic() t1 = time.monotonic()
_logger.info("Reloaded %d file(s), components in %.1fms", reload_logger.info("Reloaded %d file(s), components in %.1fms",
len(changed_files), (t1 - t0) * 1000) len(changed_files), (t1 - t0) * 1000)
# Recompute render plans for all services that have pages # Recompute render plans for all services that have pages
@@ -182,7 +353,7 @@ def reload_if_changed() -> None:
for svc in _PAGE_REGISTRY: for svc in _PAGE_REGISTRY:
t2 = time.monotonic() t2 = time.monotonic()
compute_page_render_plans(svc) compute_page_render_plans(svc)
_logger.info("Render plans for %s in %.1fms", svc, (time.monotonic() - t2) * 1000) reload_logger.info("Render plans for %s in %.1fms", svc, (time.monotonic() - t2) * 1000)
def load_service_components(service_dir: str, service_name: str | None = None) -> None: def load_service_components(service_dir: str, service_name: str | None = None) -> None:
@@ -190,12 +361,17 @@ def load_service_components(service_dir: str, service_name: str | None = None) -
Components from ``{service_dir}/sx/`` and handlers from Components from ``{service_dir}/sx/`` and handlers from
``{service_dir}/sx/handlers/`` or ``{service_dir}/sx/handlers.sx``. ``{service_dir}/sx/handlers/`` or ``{service_dir}/sx/handlers.sx``.
This is called after ``load_shared_components()`` which defers
finalization, so we finalize here (once for shared + service).
""" """
sx_dir = os.path.join(service_dir, "sx") sx_dir = os.path.join(service_dir, "sx")
if os.path.isdir(sx_dir): if os.path.isdir(sx_dir):
load_sx_dir(sx_dir) load_sx_dir(sx_dir) # finalize=True by default
watch_sx_dir(sx_dir) watch_sx_dir(sx_dir)
_rebuild_closures()
# Load handler definitions if service_name is provided # Load handler definitions if service_name is provided
if service_name: if service_name:
load_handler_dir(os.path.join(sx_dir, "handlers"), service_name) load_handler_dir(os.path.join(sx_dir, "handlers"), service_name)
@@ -213,21 +389,12 @@ def load_handler_dir(directory: str, service_name: str) -> None:
_load(directory, service_name) _load(directory, service_name)
def register_components(sx_source: str) -> None: def register_components(sx_source: str, *, _defer_postprocess: bool = False) -> None:
"""Parse and evaluate s-expression component definitions into the """Parse and evaluate s-expression component definitions into the
shared environment. shared environment.
Typically called at app startup:: When *_defer_postprocess* is True, skip deps/io_refs/hash computation.
Call ``finalize_components()`` once after all files are loaded.
register_components('''
(defcomp ~link-card (&key link title image icon)
(a :href link :class "block rounded ..."
(div :class "flex ..."
(if image
(img :src image :class "...")
(div :class "..." (i :class icon)))
(div :class "..." (div :class "..." title)))))
''')
""" """
from .ref.sx_ref import eval_expr as _raw_eval, trampoline as _trampoline from .ref.sx_ref import eval_expr as _raw_eval, trampoline as _trampoline
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env)) _eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
@@ -242,8 +409,6 @@ def register_components(sx_source: str) -> None:
_eval(expr, _COMPONENT_ENV) _eval(expr, _COMPONENT_ENV)
# Pre-scan CSS classes for newly registered components. # Pre-scan CSS classes for newly registered components.
# Scan the full source once — components from the same file share the set.
# Slightly over-counts per component but safe and avoids re-scanning at request time.
all_classes: set[str] | None = None all_classes: set[str] | None = None
for key, val in _COMPONENT_ENV.items(): for key, val in _COMPONENT_ENV.items():
if key not in existing and isinstance(val, (Component, Island)): if key not in existing and isinstance(val, (Component, Island)):
@@ -251,11 +416,18 @@ def register_components(sx_source: str) -> None:
all_classes = scan_classes_from_sx(sx_source) all_classes = scan_classes_from_sx(sx_source)
val.css_classes = set(all_classes) val.css_classes = set(all_classes)
# Recompute transitive deps for all components (cheap — just AST walking) if not _defer_postprocess:
finalize_components()
def finalize_components() -> None:
"""Compute deps, IO refs, and hash for all registered components.
Called once after all component files are loaded.
"""
from .deps import compute_all_deps, compute_all_io_refs, get_all_io_names from .deps import compute_all_deps, compute_all_io_refs, get_all_io_names
compute_all_deps(_COMPONENT_ENV) compute_all_deps(_COMPONENT_ENV)
compute_all_io_refs(_COMPONENT_ENV, get_all_io_names()) compute_all_io_refs(_COMPONENT_ENV, get_all_io_names())
_compute_component_hash() _compute_component_hash()
@@ -269,7 +441,7 @@ def sx(source: str, **kwargs: Any) -> str:
Keyword arguments are merged into the evaluation environment, Keyword arguments are merged into the evaluation environment,
so Jinja context variables can be passed through:: so Jinja context variables can be passed through::
{{ sx('(~link-card :title title :slug slug)', {{ sx('(~shared:fragments/link-card :title title :slug slug)',
title=post.title, slug=post.slug) | safe }} title=post.title, slug=post.slug) | safe }}
This is a synchronous function — suitable for Jinja globals. This is a synchronous function — suitable for Jinja globals.

View File

@@ -15,7 +15,7 @@ Usage::
# Error pages (no context needed) # Error pages (no context needed)
html = render_page( html = render_page(
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)', '(~shared:pages/error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
image="/static/errors/404.gif", image="/static/errors/404.gif",
asset_url="/static", asset_url="/static",
) )

View File

@@ -179,9 +179,9 @@ async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
def _replace_suspense_sexp(sx: str, stream_id: str, replacement: str) -> str: def _replace_suspense_sexp(sx: str, stream_id: str, replacement: str) -> str:
"""Replace a rendered ~suspense div in SX source with replacement content. """Replace a rendered ~shared:pages/suspense div in SX source with replacement content.
After _eval_slot, ~suspense expands to: After _eval_slot, ~shared:pages/suspense expands to:
(div :id "sx-suspense-{id}" :data-suspense "{id}" :style "display:contents" ...) (div :id "sx-suspense-{id}" :data-suspense "{id}" :style "display:contents" ...)
This finds the balanced s-expression containing :data-suspense "{id}" and This finds the balanced s-expression containing :data-suspense "{id}" and
replaces it with the given replacement string. replaces it with the given replacement string.
@@ -277,7 +277,7 @@ async def execute_page(
if page_def.shell_expr is not None: if page_def.shell_expr is not None:
shell_sx = await _eval_slot(page_def.shell_expr, env, ctx) shell_sx = await _eval_slot(page_def.shell_expr, env, ctx)
# Replace each rendered suspense div with resolved content. # Replace each rendered suspense div with resolved content.
# _eval_slot expands ~suspense into: # _eval_slot expands ~shared:pages/suspense into:
# (div :id "sx-suspense-X" :data-suspense "X" :style "display:contents" ...) # (div :id "sx-suspense-X" :data-suspense "X" :style "display:contents" ...)
# We find the balanced s-expr containing :data-suspense "X" and replace it. # We find the balanced s-expr containing :data-suspense "X" and replace it.
for stream_id, chunk_sx in chunks: for stream_id, chunk_sx in chunks:
@@ -534,24 +534,24 @@ async def execute_page_streaming(
# Render to HTML so [data-suspense] elements are real DOM immediately. # Render to HTML so [data-suspense] elements are real DOM immediately.
# No dependency on sx-browser.js boot timing for the initial shell. # No dependency on sx-browser.js boot timing for the initial shell.
suspense_header_sx = f'(~suspense :id "stream-headers" :fallback {header_fallback})' suspense_header_sx = f'(~shared:pages/suspense :id "stream-headers" :fallback {header_fallback})'
# When :shell is provided, it renders directly as the content slot # When :shell is provided, it renders directly as the content slot
# (it contains its own ~suspense for the data-dependent part). # (it contains its own ~shared:pages/suspense for the data-dependent part).
# Otherwise, wrap the entire :content in a single suspense. # Otherwise, wrap the entire :content in a single suspense.
if page_def.shell_expr is not None: if page_def.shell_expr is not None:
shell_content_sx = await _eval_slot(page_def.shell_expr, env, ctx) shell_content_sx = await _eval_slot(page_def.shell_expr, env, ctx)
suspense_content_sx = shell_content_sx suspense_content_sx = shell_content_sx
else: else:
suspense_content_sx = f'(~suspense :id "stream-content" :fallback {fallback_sx})' suspense_content_sx = f'(~shared:pages/suspense :id "stream-content" :fallback {fallback_sx})'
initial_page_html = await _helpers_render_to_html("app-body", initial_page_html = await _helpers_render_to_html("shared:layout/app-body",
header_rows=SxExpr(suspense_header_sx), header_rows=SxExpr(suspense_header_sx),
content=SxExpr(suspense_content_sx), content=SxExpr(suspense_content_sx),
) )
# Include layout component refs + page content so the scan picks up # Include layout component refs + page content so the scan picks up
# their transitive deps (e.g. ~cart-mini, ~auth-menu in headers). # their transitive deps (e.g. ~shared:fragments/cart-mini, ~auth-menu in headers).
layout_refs = "" layout_refs = ""
if layout is not None and hasattr(layout, "component_names"): if layout is not None and hasattr(layout, "component_names"):
layout_refs = " ".join(f"({n})" for n in layout.component_names) layout_refs = " ".join(f"({n})" for n in layout.component_names)
@@ -561,14 +561,14 @@ async def execute_page_streaming(
shell_ref = "" shell_ref = ""
if page_def.shell_expr is not None: if page_def.shell_expr is not None:
shell_ref = sx_serialize(page_def.shell_expr) shell_ref = sx_serialize(page_def.shell_expr)
page_sx_for_scan = f'(<> {layout_refs} {content_ref} {shell_ref} (~app-body :header-rows {suspense_header_sx} :content {suspense_content_sx}))' page_sx_for_scan = f'(<> {layout_refs} {content_ref} {shell_ref} (~shared:layout/app-body :header-rows {suspense_header_sx} :content {suspense_content_sx}))'
shell, tail = sx_page_streaming_parts( shell, tail = sx_page_streaming_parts(
tctx, initial_page_html, page_sx=page_sx_for_scan, tctx, initial_page_html, page_sx=page_sx_for_scan,
) )
# Capture component env + extras scanner while we still have context. # Capture component env + extras scanner while we still have context.
# Resolved SX may reference components not in the initial scan # Resolved SX may reference components not in the initial scan
# (e.g. ~cart-mini from IO-generated header content). # (e.g. ~shared:fragments/cart-mini from IO-generated header content).
from .jinja_bridge import components_for_page as _comp_scan from .jinja_bridge import components_for_page as _comp_scan
from quart import current_app as _ca from quart import current_app as _ca
_service = _ca.name _service = _ca.name

View File

@@ -106,6 +106,14 @@ def _unescape_string(s: str) -> str:
while i < len(s): while i < len(s):
if s[i] == "\\" and i + 1 < len(s): if s[i] == "\\" and i + 1 < len(s):
nxt = s[i + 1] nxt = s[i + 1]
if nxt == "u" and i + 5 < len(s):
hex_str = s[i + 2:i + 6]
try:
out.append(chr(int(hex_str, 16)))
i += 6
continue
except ValueError:
pass # fall through to default handling
out.append(_ESCAPE_MAP.get(nxt, nxt)) out.append(_ESCAPE_MAP.get(nxt, nxt))
i += 2 i += 2
else: else:

View File

@@ -48,6 +48,7 @@
"string" (escape-html expr) "string" (escape-html expr)
"number" (escape-html (str expr)) "number" (escape-html (str expr))
"raw-html" (raw-html-content expr) "raw-html" (raw-html-content expr)
"spread" (do (emit! "element-attrs" (spread-attrs expr)) "")
"symbol" (let ((val (async-eval expr env ctx))) "symbol" (let ((val (async-eval expr env ctx)))
(async-render val env ctx)) (async-render val env ctx))
"keyword" (escape-html (keyword-name expr)) "keyword" (escape-html (keyword-name expr))
@@ -167,16 +168,25 @@
(let ((class-val (dict-get attrs "class"))) (let ((class-val (dict-get attrs "class")))
(when (and (not (nil? class-val)) (not (= class-val false))) (when (and (not (nil? class-val)) (not (= class-val false)))
(css-class-collect! (str class-val)))) (css-class-collect! (str class-val))))
;; Build opening tag (if (contains? VOID_ELEMENTS tag)
(let ((opening (str "<" tag (render-attrs attrs) ">"))) (str "<" tag (render-attrs attrs) ">")
(if (contains? VOID_ELEMENTS tag) ;; Provide scope for spread emit!
opening (let ((token (if (or (= tag "svg") (= tag "math"))
(let ((token (if (or (= tag "svg") (= tag "math")) (svg-context-set! true)
(svg-context-set! true) nil))
nil)) (content-parts (list)))
(child-html (join "" (async-map-render children env ctx)))) (provide-push! "element-attrs" nil)
(when token (svg-context-reset! token)) (for-each
(str opening child-html "</" tag ">"))))))) (fn (c) (append! content-parts (async-render c env ctx)))
children)
(for-each
(fn (spread-dict) (merge-spread-attrs attrs spread-dict))
(emitted "element-attrs"))
(provide-pop! "element-attrs")
(when token (svg-context-reset! token))
(str "<" tag (render-attrs attrs) ">"
(join "" content-parts)
"</" tag ">"))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -221,10 +231,14 @@
(for-each (for-each
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) (fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp)) (component-params comp))
;; Pre-render children to raw HTML
(when (component-has-children? comp) (when (component-has-children? comp)
(env-set! local "children" (let ((parts (list)))
(make-raw-html (for-each
(join "" (async-map-render children env ctx))))) (fn (c) (append! parts (async-render c env ctx)))
children)
(env-set! local "children"
(make-raw-html (join "" parts)))))
(async-render (component-body comp) local ctx))))) (async-render (component-body comp) local ctx)))))
@@ -242,10 +256,14 @@
(for-each (for-each
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) (fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params island)) (component-params island))
;; Pre-render children
(when (component-has-children? island) (when (component-has-children? island)
(env-set! local "children" (let ((parts (list)))
(make-raw-html (for-each
(join "" (async-map-render children env ctx))))) (fn (c) (append! parts (async-render c env ctx)))
children)
(env-set! local "children"
(make-raw-html (join "" parts)))))
(let ((body-html (async-render (component-body island) local ctx)) (let ((body-html (async-render (component-body island) local ctx))
(state-json (serialize-island-state kwargs))) (state-json (serialize-island-state kwargs)))
(str "<span data-sx-island=\"" (escape-attr island-name) "\"" (str "<span data-sx-island=\"" (escape-attr island-name) "\""
@@ -317,7 +335,7 @@
(list "if" "when" "cond" "case" "let" "let*" "begin" "do" (list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"deftype" "defeffect" "deftype" "defeffect"
"map" "map-indexed" "filter" "for-each")) "map" "map-indexed" "filter" "for-each" "provide"))
(define async-render-form? :effects [] (define async-render-form? :effects []
(fn ((name :as string)) (fn ((name :as string))
@@ -343,11 +361,13 @@
(async-render (nth expr 3) env ctx) (async-render (nth expr 3) env ctx)
""))) "")))
;; when ;; when — single body: pass through. Multi: join strings.
(= name "when") (= name "when")
(if (not (async-eval (nth expr 1) env ctx)) (if (not (async-eval (nth expr 1) env ctx))
"" ""
(join "" (async-map-render (slice expr 2) env ctx))) (if (= (len expr) 3)
(async-render (nth expr 2) env ctx)
(join "" (async-map-render (slice expr 2) env ctx))))
;; cond — uses cond-scheme? (every? check) from eval.sx ;; cond — uses cond-scheme? (every? check) from eval.sx
(= name "cond") (= name "cond")
@@ -360,14 +380,18 @@
(= name "case") (= name "case")
(async-render (async-eval expr env ctx) env ctx) (async-render (async-eval expr env ctx) env ctx)
;; let / let* ;; let / let* — single body: pass through. Multi: join strings.
(or (= name "let") (= name "let*")) (or (= name "let") (= name "let*"))
(let ((local (async-process-bindings (nth expr 1) env ctx))) (let ((local (async-process-bindings (nth expr 1) env ctx)))
(join "" (async-map-render (slice expr 2) local ctx))) (if (= (len expr) 3)
(async-render (nth expr 2) local ctx)
(join "" (async-map-render (slice expr 2) local ctx))))
;; begin / do ;; begin / do — single body: pass through. Multi: join strings.
(or (= name "begin") (= name "do")) (or (= name "begin") (= name "do"))
(join "" (async-map-render (rest expr) env ctx)) (if (= (len expr) 2)
(async-render (nth expr 1) env ctx)
(join "" (async-map-render (rest expr) env ctx)))
;; Definition forms ;; Definition forms
(definition-form? name) (definition-form? name)
@@ -377,15 +401,13 @@
(= name "map") (= name "map")
(let ((f (async-eval (nth expr 1) env ctx)) (let ((f (async-eval (nth expr 1) env ctx))
(coll (async-eval (nth expr 2) env ctx))) (coll (async-eval (nth expr 2) env ctx)))
(join "" (join "" (async-map-fn-render f coll env ctx)))
(async-map-fn-render f coll env ctx)))
;; map-indexed ;; map-indexed
(= name "map-indexed") (= name "map-indexed")
(let ((f (async-eval (nth expr 1) env ctx)) (let ((f (async-eval (nth expr 1) env ctx))
(coll (async-eval (nth expr 2) env ctx))) (coll (async-eval (nth expr 2) env ctx)))
(join "" (join "" (async-map-indexed-fn-render f coll env ctx)))
(async-map-indexed-fn-render f coll env ctx)))
;; filter — eval fully then render ;; filter — eval fully then render
(= name "filter") (= name "filter")
@@ -395,8 +417,20 @@
(= name "for-each") (= name "for-each")
(let ((f (async-eval (nth expr 1) env ctx)) (let ((f (async-eval (nth expr 1) env ctx))
(coll (async-eval (nth expr 2) env ctx))) (coll (async-eval (nth expr 2) env ctx)))
(join "" (join "" (async-map-fn-render f coll env ctx)))
(async-map-fn-render f coll env ctx)))
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (async-eval (nth expr 1) env ctx))
(prov-val (async-eval (nth expr 2) env ctx))
(body-start 3)
(body-count (- (len expr) 3)))
(provide-push! prov-name prov-val)
(let ((result (if (= body-count 1)
(async-render (nth expr body-start) env ctx)
(join "" (async-map-render (slice expr body-start) env ctx)))))
(provide-pop! prov-name)
result))
;; Fallback ;; Fallback
:else :else
@@ -545,32 +579,34 @@
(define-async async-aser :effects [render io] (define-async async-aser :effects [render io]
(fn (expr (env :as dict) ctx) (fn (expr (env :as dict) ctx)
(case (type-of expr) (let ((t (type-of expr))
"number" expr (result nil))
"string" expr (cond
"boolean" expr (= t "number") (set! result expr)
"nil" nil (= t "string") (set! result expr)
(= t "boolean") (set! result expr)
"symbol" (= t "nil") (set! result nil)
(let ((name (symbol-name expr))) (= t "symbol")
(cond (let ((name (symbol-name expr)))
(env-has? env name) (env-get env name) (set! result
(primitive? name) (get-primitive name) (cond
(= name "true") true (env-has? env name) (env-get env name)
(= name "false") false (primitive? name) (get-primitive name)
(= name "nil") nil (= name "true") true
:else (error (str "Undefined symbol: " name)))) (= name "false") false
(= name "nil") nil
"keyword" (keyword-name expr) :else (error (str "Undefined symbol: " name)))))
(= t "keyword") (set! result (keyword-name expr))
"dict" (async-aser-dict expr env ctx) (= t "dict") (set! result (async-aser-dict expr env ctx))
;; Spread — emit attrs to nearest element provider
"list" (= t "spread") (do (emit! "element-attrs" (spread-attrs expr))
(if (empty? expr) (set! result nil))
(list) (= t "list") (set! result (if (empty? expr) (list) (async-aser-list expr env ctx)))
(async-aser-list expr env ctx)) :else (set! result expr))
;; Catch spread values from function calls and symbol lookups
:else expr))) (if (spread? result)
(do (emit! "element-attrs" (spread-attrs result)) nil)
result))))
(define-async async-aser-dict :effects [render io] (define-async async-aser-dict :effects [render io]
@@ -806,9 +842,12 @@
(let ((token (if (or (= name "svg") (= name "math")) (let ((token (if (or (= name "svg") (= name "math"))
(svg-context-set! true) (svg-context-set! true)
nil)) nil))
(parts (list name)) (attr-parts (list))
(child-parts (list))
(skip false) (skip false)
(i 0)) (i 0))
;; Provide scope for spread emit!
(provide-push! "element-attrs" nil)
(for-each (for-each
(fn (arg) (fn (arg)
(if skip (if skip
@@ -818,16 +857,16 @@
(< (inc i) (len args))) (< (inc i) (len args)))
(let ((val (async-aser (nth args (inc i)) env ctx))) (let ((val (async-aser (nth args (inc i)) env ctx)))
(when (not (nil? val)) (when (not (nil? val))
(append! parts (str ":" (keyword-name arg))) (append! attr-parts (str ":" (keyword-name arg)))
(if (= (type-of val) "list") (if (= (type-of val) "list")
(let ((live (filter (fn (v) (not (nil? v))) val))) (let ((live (filter (fn (v) (not (nil? v))) val)))
(if (empty? live) (if (empty? live)
(append! parts "nil") (append! attr-parts "nil")
(let ((items (map serialize live))) (let ((items (map serialize live)))
(if (some (fn (v) (sx-expr? v)) live) (if (some (fn (v) (sx-expr? v)) live)
(append! parts (str "(<> " (join " " items) ")")) (append! attr-parts (str "(<> " (join " " items) ")"))
(append! parts (str "(list " (join " " items) ")")))))) (append! attr-parts (str "(list " (join " " items) ")"))))))
(append! parts (serialize val)))) (append! attr-parts (serialize val))))
(set! skip true) (set! skip true)
(set! i (inc i))) (set! i (inc i)))
(let ((result (async-aser arg env ctx))) (let ((result (async-aser arg env ctx)))
@@ -836,13 +875,25 @@
(for-each (for-each
(fn (item) (fn (item)
(when (not (nil? item)) (when (not (nil? item))
(append! parts (serialize item)))) (append! child-parts (serialize item))))
result) result)
(append! parts (serialize result)))) (append! child-parts (serialize result))))
(set! i (inc i)))))) (set! i (inc i))))))
args) args)
;; Collect emitted spread attrs — after explicit attrs, before children
(for-each
(fn (spread-dict)
(for-each
(fn (k)
(let ((v (dict-get spread-dict k)))
(append! attr-parts (str ":" k))
(append! attr-parts (serialize v))))
(keys spread-dict)))
(emitted "element-attrs"))
(provide-pop! "element-attrs")
(when token (svg-context-reset! token)) (when token (svg-context-reset! token))
(make-sx-expr (str "(" (join " " parts) ")"))))) (let ((parts (concat (list name) attr-parts child-parts)))
(make-sx-expr (str "(" (join " " parts) ")"))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -855,7 +906,7 @@
"define" "defcomp" "defmacro" "defstyle" "define" "defcomp" "defmacro" "defstyle"
"defhandler" "defpage" "defquery" "defaction" "defhandler" "defpage" "defquery" "defaction"
"begin" "do" "quote" "->" "set!" "defisland" "begin" "do" "quote" "->" "set!" "defisland"
"deftype" "defeffect")) "deftype" "defeffect" "provide"))
(define ASYNC_ASER_HO_NAMES (define ASYNC_ASER_HO_NAMES
(list "map" "map-indexed" "filter" "for-each")) (list "map" "map-indexed" "filter" "for-each"))
@@ -993,6 +1044,17 @@
(= name "deftype") (= name "defeffect")) (= name "deftype") (= name "defeffect"))
(do (async-eval expr env ctx) nil) (do (async-eval expr env ctx) nil)
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (async-eval (first args) env ctx))
(prov-val (async-eval (nth args 1) env ctx))
(result nil))
(provide-push! prov-name prov-val)
(for-each (fn (body) (set! result (async-aser body env ctx)))
(slice args 2))
(provide-pop! prov-name)
result)
;; Fallback ;; Fallback
:else :else
(async-eval expr env ctx))))) (async-eval expr env ctx)))))
@@ -1250,6 +1312,14 @@
;; (svg-context-reset! token) — reset SVG context ;; (svg-context-reset! token) — reset SVG context
;; (css-class-collect! val) — collect CSS classes ;; (css-class-collect! val) — collect CSS classes
;; ;;
;; Spread + collect (from render.sx):
;; (spread? x) — check if spread value
;; (spread-attrs s) — extract attrs dict from spread
;; (merge-spread-attrs tgt src) — merge spread attrs onto target
;; (collect! bucket value) — add to render-time accumulator
;; (collected bucket) — read render-time accumulator
;; (clear-collected! bucket) — clear accumulator
;;
;; Raw HTML: ;; Raw HTML:
;; (is-raw-html? x) — check if raw HTML marker ;; (is-raw-html? x) — check if raw HTML marker
;; (make-raw-html s) — wrap string as raw HTML ;; (make-raw-html s) — wrap string as raw HTML

View File

@@ -44,6 +44,9 @@
;; Pre-rendered DOM node → pass through ;; Pre-rendered DOM node → pass through
"dom-node" expr "dom-node" expr
;; Spread → emit attrs to nearest element provider, pass through for reactive-spread
"spread" (do (emit! "element-attrs" (spread-attrs expr)) expr)
;; Dict → empty ;; Dict → empty
"dict" (create-fragment) "dict" (create-fragment)
@@ -157,7 +160,11 @@
;; Data list ;; Data list
:else :else
(let ((frag (create-fragment))) (let ((frag (create-fragment)))
(for-each (fn (x) (dom-append frag (render-to-dom x env ns))) expr) (for-each (fn (x)
(let ((result (render-to-dom x env ns)))
(when (not (spread? result))
(dom-append frag result))))
expr)
frag))))) frag)))))
@@ -173,6 +180,9 @@
:else ns)) :else ns))
(el (dom-create-element tag new-ns))) (el (dom-create-element tag new-ns)))
;; Provide scope for spread emit! — deeply nested spreads emit here
(provide-push! "element-attrs" nil)
;; Process args: keywords → attrs, others → children ;; Process args: keywords → attrs, others → children
(reduce (reduce
(fn (state arg) (fn (state arg)
@@ -221,14 +231,46 @@
(dom-set-attr el attr-name (str attr-val))))) (dom-set-attr el attr-name (str attr-val)))))
(assoc state "skip" true "i" (inc (get state "i")))) (assoc state "skip" true "i" (inc (get state "i"))))
;; Positional arg → child ;; Positional arg → child (or spread → merge attrs onto element)
(do (do
(when (not (contains? VOID_ELEMENTS tag)) (when (not (contains? VOID_ELEMENTS tag))
(dom-append el (render-to-dom arg env new-ns))) (let ((child (render-to-dom arg env new-ns)))
(cond
;; Reactive spread: track signal deps, update attrs on change
(and (spread? child) *island-scope*)
(reactive-spread el (fn () (render-to-dom arg env new-ns)))
;; Static spread: already emitted via provide, skip
(spread? child) nil
;; Normal child: append to element
:else
(dom-append el child))))
(assoc state "i" (inc (get state "i")))))))) (assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false) (dict "i" 0 "skip" false)
args) args)
;; Collect emitted spread attrs and merge onto DOM element
(for-each
(fn (spread-dict)
(for-each
(fn ((key :as string))
(let ((val (dict-get spread-dict key)))
(if (= key "class")
(let ((existing (dom-get-attr el "class")))
(dom-set-attr el "class"
(if (and existing (not (= existing "")))
(str existing " " val)
val)))
(if (= key "style")
(let ((existing (dom-get-attr el "style")))
(dom-set-attr el "style"
(if (and existing (not (= existing "")))
(str existing ";" val)
val)))
(dom-set-attr el key (str val))))))
(keys spread-dict)))
(emitted "element-attrs"))
(provide-pop! "element-attrs")
el))) el)))
@@ -269,10 +311,14 @@
(component-params comp)) (component-params comp))
;; If component accepts children, pre-render them to a fragment ;; If component accepts children, pre-render them to a fragment
;; Spread values are filtered out (no parent element to merge onto)
(when (component-has-children? comp) (when (component-has-children? comp)
(let ((child-frag (create-fragment))) (let ((child-frag (create-fragment)))
(for-each (for-each
(fn (c) (dom-append child-frag (render-to-dom c env ns))) (fn (c)
(let ((result (render-to-dom c env ns)))
(when (not (spread? result))
(dom-append child-frag result))))
children) children)
(env-set! local "children" child-frag))) (env-set! local "children" child-frag)))
@@ -287,7 +333,10 @@
(fn ((args :as list) (env :as dict) (ns :as string)) (fn ((args :as list) (env :as dict) (ns :as string))
(let ((frag (create-fragment))) (let ((frag (create-fragment)))
(for-each (for-each
(fn (x) (dom-append frag (render-to-dom x env ns))) (fn (x)
(let ((result (render-to-dom x env ns)))
(when (not (spread? result))
(dom-append frag result))))
args) args)
frag))) frag)))
@@ -332,7 +381,7 @@
(list "if" "when" "cond" "case" "let" "let*" "begin" "do" (list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"map" "map-indexed" "filter" "for-each" "portal" "map" "map-indexed" "filter" "for-each" "portal"
"error-boundary")) "error-boundary" "provide"))
(define render-dom-form? :effects [] (define render-dom-form? :effects []
(fn ((name :as string)) (fn ((name :as string))
@@ -368,16 +417,19 @@
(dom-insert-after marker result)) (dom-insert-after marker result))
;; Marker not yet in DOM (first run) — just save result ;; Marker not yet in DOM (first run) — just save result
(set! initial-result result))))) (set! initial-result result)))))
;; Return fragment: marker + initial render result ;; Spread pass-through: spreads aren't DOM nodes, can't live
(let ((frag (create-fragment))) ;; in fragments. Return directly so parent element merges attrs.
(dom-append frag marker) (if (spread? initial-result)
(when initial-result initial-result
(set! current-nodes (let ((frag (create-fragment)))
(if (dom-is-fragment? initial-result) (dom-append frag marker)
(dom-child-nodes initial-result) (when initial-result
(list initial-result))) (set! current-nodes
(dom-append frag initial-result)) (if (dom-is-fragment? initial-result)
frag)) (dom-child-nodes initial-result)
(list initial-result)))
(dom-append frag initial-result))
frag)))
;; Static if ;; Static if
(let ((cond-val (trampoline (eval-expr (nth expr 1) env)))) (let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val (if cond-val
@@ -415,10 +467,13 @@
(range 2 (len expr))) (range 2 (len expr)))
(set! current-nodes (dom-child-nodes frag)) (set! current-nodes (dom-child-nodes frag))
(set! initial-result frag)))))) (set! initial-result frag))))))
(let ((frag (create-fragment))) ;; Spread pass-through
(dom-append frag marker) (if (spread? initial-result)
(when initial-result (dom-append frag initial-result)) initial-result
frag)) (let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result (dom-append frag initial-result))
frag)))
;; Static when ;; Static when
(if (not (trampoline (eval-expr (nth expr 1) env))) (if (not (trampoline (eval-expr (nth expr 1) env)))
(create-fragment) (create-fragment)
@@ -457,10 +512,13 @@
(dom-child-nodes result) (dom-child-nodes result)
(list result))) (list result)))
(set! initial-result result))))))) (set! initial-result result)))))))
(let ((frag (create-fragment))) ;; Spread pass-through
(dom-append frag marker) (if (spread? initial-result)
(when initial-result (dom-append frag initial-result)) initial-result
frag)) (let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result (dom-append frag initial-result))
frag)))
;; Static cond ;; Static cond
(let ((branch (eval-cond (rest expr) env))) (let ((branch (eval-cond (rest expr) env)))
(if branch (if branch
@@ -471,24 +529,32 @@
(= name "case") (= name "case")
(render-to-dom (trampoline (eval-expr expr env)) env ns) (render-to-dom (trampoline (eval-expr expr env)) env ns)
;; let / let* ;; let / let* — single body: pass through (spread propagates). Multi: fragment.
(or (= name "let") (= name "let*")) (or (= name "let") (= name "let*"))
(let ((local (process-bindings (nth expr 1) env)) (let ((local (process-bindings (nth expr 1) env)))
(frag (create-fragment))) (if (= (len expr) 3)
(for-each (render-to-dom (nth expr 2) local ns)
(fn (i) (let ((frag (create-fragment)))
(dom-append frag (render-to-dom (nth expr i) local ns))) (for-each
(range 2 (len expr))) (fn (i)
frag) (let ((result (render-to-dom (nth expr i) local ns)))
(when (not (spread? result))
(dom-append frag result))))
(range 2 (len expr)))
frag)))
;; begin / do ;; begin / do — single body: pass through. Multi: fragment.
(or (= name "begin") (= name "do")) (or (= name "begin") (= name "do"))
(let ((frag (create-fragment))) (if (= (len expr) 2)
(for-each (render-to-dom (nth expr 1) env ns)
(fn (i) (let ((frag (create-fragment)))
(dom-append frag (render-to-dom (nth expr i) env ns))) (for-each
(range 1 (len expr))) (fn (i)
frag) (let ((result (render-to-dom (nth expr i) env ns)))
(when (not (spread? result))
(dom-append frag result))))
(range 1 (len expr)))
frag))
;; Definition forms — eval for side effects ;; Definition forms — eval for side effects
(definition-form? name) (definition-form? name)
@@ -571,6 +637,19 @@
coll) coll)
frag) frag)
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (trampoline (eval-expr (nth expr 1) env)))
(prov-val (trampoline (eval-expr (nth expr 2) env)))
(frag (create-fragment)))
(provide-push! prov-name prov-val)
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 3 (len expr)))
(provide-pop! prov-name)
frag)
;; Fallback ;; Fallback
:else :else
(render-to-dom (trampoline (eval-expr expr env)) env ns)))) (render-to-dom (trampoline (eval-expr expr env)) env ns))))
@@ -799,6 +878,64 @@
:else :else
(dom-set-attr el attr-name (str val))))))))) (dom-set-attr el attr-name (str val)))))))))
;; reactive-spread — reactively bind spread attrs to parent element.
;; Used when a child of an element produces a spread inside an island.
;; Tracks signal deps in the spread expression. When signals change:
;; old classes are removed, new ones applied. Non-class attrs (data-tw etc.)
;; are overwritten. Flushes newly collected CSS rules to live stylesheet.
;;
;; Multiple reactive spreads on the same element are safe — each tracks
;; its own class contribution and only removes/adds its own tokens.
(define reactive-spread :effects [render mutation]
(fn (el (render-fn :as lambda))
(let ((prev-classes (list))
(prev-extra-keys (list)))
;; Mark for morph protection
(let ((existing (or (dom-get-attr el "data-sx-reactive-attrs") "")))
(dom-set-attr el "data-sx-reactive-attrs"
(if (empty? existing) "_spread" (str existing ",_spread"))))
(effect (fn ()
;; 1. Remove previously applied classes from element's class list
(when (not (empty? prev-classes))
(let ((current (or (dom-get-attr el "class") ""))
(tokens (filter (fn (c) (not (= c ""))) (split current " ")))
(kept (filter (fn (c)
(not (some (fn (pc) (= pc c)) prev-classes)))
tokens)))
(if (empty? kept)
(dom-remove-attr el "class")
(dom-set-attr el "class" (join " " kept)))))
;; 2. Remove previously applied extra attrs
(for-each (fn (k) (dom-remove-attr el k)) prev-extra-keys)
;; 3. Re-evaluate the spread expression (tracks signal deps)
(let ((result (render-fn)))
(if (spread? result)
(let ((attrs (spread-attrs result))
(cls-str (or (dict-get attrs "class") ""))
(new-classes (filter (fn (c) (not (= c "")))
(split cls-str " ")))
(extra-keys (filter (fn (k) (not (= k "class")))
(keys attrs))))
(set! prev-classes new-classes)
(set! prev-extra-keys extra-keys)
;; Append new classes to element
(when (not (empty? new-classes))
(let ((current (or (dom-get-attr el "class") "")))
(dom-set-attr el "class"
(if (and current (not (= current "")))
(str current " " cls-str)
cls-str))))
;; Set extra attrs (data-tw, etc.) — simple overwrite
(for-each (fn (k)
(dom-set-attr el k (str (dict-get attrs k))))
extra-keys)
;; Flush any newly collected CSS rules to live stylesheet
(flush-cssx-to-dom))
;; No longer a spread — clear tracked state
(do
(set! prev-classes (list))
(set! prev-extra-keys (list))))))))))
;; reactive-fragment — conditionally render a fragment based on a signal ;; reactive-fragment — conditionally render a fragment based on a signal
;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island. ;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island.
(define reactive-fragment :effects [render mutation] (define reactive-fragment :effects [render mutation]

View File

@@ -30,6 +30,8 @@
"keyword" (escape-html (keyword-name expr)) "keyword" (escape-html (keyword-name expr))
;; Raw HTML passthrough ;; Raw HTML passthrough
"raw-html" (raw-html-content expr) "raw-html" (raw-html-content expr)
;; Spread — emit attrs to nearest element provider
"spread" (do (emit! "element-attrs" (spread-attrs expr)) "")
;; Everything else — evaluate first ;; Everything else — evaluate first
:else (render-value-to-html (trampoline (eval-expr expr env)) env)))) :else (render-value-to-html (trampoline (eval-expr expr env)) env))))
@@ -42,6 +44,7 @@
"boolean" (if val "true" "false") "boolean" (if val "true" "false")
"list" (render-list-to-html val env) "list" (render-list-to-html val env)
"raw-html" (raw-html-content val) "raw-html" (raw-html-content val)
"spread" (do (emit! "element-attrs" (spread-attrs val)) "")
:else (escape-html (str val))))) :else (escape-html (str val)))))
@@ -53,7 +56,7 @@
(list "if" "when" "cond" "case" "let" "let*" "begin" "do" (list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"deftype" "defeffect" "deftype" "defeffect"
"map" "map-indexed" "filter" "for-each")) "map" "map-indexed" "filter" "for-each" "provide"))
(define render-html-form? :effects [] (define render-html-form? :effects []
(fn ((name :as string)) (fn ((name :as string))
@@ -147,14 +150,14 @@
(render-to-html (nth expr 3) env) (render-to-html (nth expr 3) env)
""))) "")))
;; when ;; when — single body: pass through. Multi: join strings.
(= name "when") (= name "when")
(if (not (trampoline (eval-expr (nth expr 1) env))) (if (not (trampoline (eval-expr (nth expr 1) env)))
"" ""
(join "" (if (= (len expr) 3)
(map (render-to-html (nth expr 2) env)
(fn (i) (render-to-html (nth expr i) env)) (join "" (map (fn (i) (render-to-html (nth expr i) env))
(range 2 (len expr))))) (range 2 (len expr))))))
;; cond ;; cond
(= name "cond") (= name "cond")
@@ -167,20 +170,20 @@
(= name "case") (= name "case")
(render-to-html (trampoline (eval-expr expr env)) env) (render-to-html (trampoline (eval-expr expr env)) env)
;; let / let* ;; let / let* — single body: pass through. Multi: join strings.
(or (= name "let") (= name "let*")) (or (= name "let") (= name "let*"))
(let ((local (process-bindings (nth expr 1) env))) (let ((local (process-bindings (nth expr 1) env)))
(join "" (if (= (len expr) 3)
(map (render-to-html (nth expr 2) local)
(fn (i) (render-to-html (nth expr i) local)) (join "" (map (fn (i) (render-to-html (nth expr i) local))
(range 2 (len expr))))) (range 2 (len expr))))))
;; begin / do ;; begin / do — single body: pass through. Multi: join strings.
(or (= name "begin") (= name "do")) (or (= name "begin") (= name "do"))
(join "" (if (= (len expr) 2)
(map (render-to-html (nth expr 1) env)
(fn (i) (render-to-html (nth expr i) env)) (join "" (map (fn (i) (render-to-html (nth expr i) env))
(range 1 (len expr)))) (range 1 (len expr)))))
;; Definition forms — eval for side effects ;; Definition forms — eval for side effects
(definition-form? name) (definition-form? name)
@@ -226,6 +229,20 @@
(render-to-html (apply f (list item)) env))) (render-to-html (apply f (list item)) env)))
coll))) coll)))
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (trampoline (eval-expr (nth expr 1) env)))
(prov-val (trampoline (eval-expr (nth expr 2) env)))
(body-start 3)
(body-count (- (len expr) 3)))
(provide-push! prov-name prov-val)
(let ((result (if (= body-count 1)
(render-to-html (nth expr body-start) env)
(join "" (map (fn (i) (render-to-html (nth expr i) env))
(range body-start (+ body-start body-count)))))))
(provide-pop! prov-name)
result))
;; Fallback ;; Fallback
:else :else
(render-value-to-html (trampoline (eval-expr expr env)) env)))) (render-value-to-html (trampoline (eval-expr expr env)) env))))
@@ -283,8 +300,7 @@
;; If component accepts children, pre-render them to raw HTML ;; If component accepts children, pre-render them to raw HTML
(when (component-has-children? comp) (when (component-has-children? comp)
(env-set! local "children" (env-set! local "children"
(make-raw-html (make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
(join "" (map (fn (c) (render-to-html c env)) children)))))
(render-to-html (component-body comp) local))))) (render-to-html (component-body comp) local)))))
@@ -294,13 +310,19 @@
(attrs (first parsed)) (attrs (first parsed))
(children (nth parsed 1)) (children (nth parsed 1))
(is-void (contains? VOID_ELEMENTS tag))) (is-void (contains? VOID_ELEMENTS tag)))
(str "<" tag (if is-void
(render-attrs attrs) (str "<" tag (render-attrs attrs) " />")
(if is-void ;; Provide scope for spread emit!
" />" (do
(str ">" (provide-push! "element-attrs" nil)
(join "" (map (fn (c) (render-to-html c env)) children)) (let ((content (join "" (map (fn (c) (render-to-html c env)) children))))
"</" tag ">")))))) (for-each
(fn (spread-dict) (merge-spread-attrs attrs spread-dict))
(emitted "element-attrs"))
(provide-pop! "element-attrs")
(str "<" tag (render-attrs attrs) ">"
content
"</" tag ">")))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -335,9 +357,17 @@
(assoc state "i" (inc (get state "i")))))))) (assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false) (dict "i" 0 "skip" false)
args) args)
(str "<" lake-tag " data-sx-lake=\"" (escape-attr (or lake-id "")) "\">" ;; Provide scope for spread emit!
(join "" (map (fn (c) (render-to-html c env)) children)) (let ((lake-attrs (dict "data-sx-lake" (or lake-id ""))))
"</" lake-tag ">")))) (provide-push! "element-attrs" nil)
(let ((content (join "" (map (fn (c) (render-to-html c env)) children))))
(for-each
(fn (spread-dict) (merge-spread-attrs lake-attrs spread-dict))
(emitted "element-attrs"))
(provide-pop! "element-attrs")
(str "<" lake-tag (render-attrs lake-attrs) ">"
content
"</" lake-tag ">"))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -375,9 +405,17 @@
(assoc state "i" (inc (get state "i")))))))) (assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false) (dict "i" 0 "skip" false)
args) args)
(str "<" marsh-tag " data-sx-marsh=\"" (escape-attr (or marsh-id "")) "\">" ;; Provide scope for spread emit!
(join "" (map (fn (c) (render-to-html c env)) children)) (let ((marsh-attrs (dict "data-sx-marsh" (or marsh-id ""))))
"</" marsh-tag ">")))) (provide-push! "element-attrs" nil)
(let ((content (join "" (map (fn (c) (render-to-html c env)) children))))
(for-each
(fn (spread-dict) (merge-spread-attrs marsh-attrs spread-dict))
(emitted "element-attrs"))
(provide-pop! "element-attrs")
(str "<" marsh-tag (render-attrs marsh-attrs) ">"
content
"</" marsh-tag ">"))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -429,8 +467,7 @@
;; If island accepts children, pre-render them to raw HTML ;; If island accepts children, pre-render them to raw HTML
(when (component-has-children? island) (when (component-has-children? island)
(env-set! local "children" (env-set! local "children"
(make-raw-html (make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
(join "" (map (fn (c) (render-to-html c env)) children)))))
;; Render the island body as HTML ;; Render the island body as HTML
(let ((body-html (render-to-html (component-body island) local)) (let ((body-html (render-to-html (component-body island) local))

View File

@@ -25,30 +25,38 @@
;; Evaluate for SX wire format — serialize rendering forms, ;; Evaluate for SX wire format — serialize rendering forms,
;; evaluate control flow and function calls. ;; evaluate control flow and function calls.
(set-render-active! true) (set-render-active! true)
(case (type-of expr) (let ((result
"number" expr (case (type-of expr)
"string" expr "number" expr
"boolean" expr "string" expr
"nil" nil "boolean" expr
"nil" nil
"symbol" "symbol"
(let ((name (symbol-name expr))) (let ((name (symbol-name expr)))
(cond (cond
(env-has? env name) (env-get env name) (env-has? env name) (env-get env name)
(primitive? name) (get-primitive name) (primitive? name) (get-primitive name)
(= name "true") true (= name "true") true
(= name "false") false (= name "false") false
(= name "nil") nil (= name "nil") nil
:else (error (str "Undefined symbol: " name)))) :else (error (str "Undefined symbol: " name))))
"keyword" (keyword-name expr) "keyword" (keyword-name expr)
"list" "list"
(if (empty? expr) (if (empty? expr)
(list) (list)
(aser-list expr env)) (aser-list expr env))
:else expr))) ;; Spread — emit attrs to nearest element provider
"spread" (do (emit! "element-attrs" (spread-attrs expr)) nil)
:else expr)))
;; Catch spread values from function calls and symbol lookups
(if (spread? result)
(do (emit! "element-attrs" (spread-attrs result)) nil)
result))))
(define aser-list :effects [render] (define aser-list :effects [render]
@@ -130,9 +138,13 @@
;; Serialize (name :key val child ...) — evaluate args but keep as sx ;; Serialize (name :key val child ...) — evaluate args but keep as sx
;; Uses for-each + mutable state (not reduce) so bootstrapper emits for-loops ;; Uses for-each + mutable state (not reduce) so bootstrapper emits for-loops
;; that can contain nested for-each for list flattening. ;; that can contain nested for-each for list flattening.
(let ((parts (list name)) ;; Separate attrs and children so emitted spread attrs go before children.
(let ((attr-parts (list))
(child-parts (list))
(skip false) (skip false)
(i 0)) (i 0))
;; Provide scope for spread emit!
(provide-push! "element-attrs" nil)
(for-each (for-each
(fn (arg) (fn (arg)
(if skip (if skip
@@ -142,8 +154,8 @@
(< (inc i) (len args))) (< (inc i) (len args)))
(let ((val (aser (nth args (inc i)) env))) (let ((val (aser (nth args (inc i)) env)))
(when (not (nil? val)) (when (not (nil? val))
(append! parts (str ":" (keyword-name arg))) (append! attr-parts (str ":" (keyword-name arg)))
(append! parts (serialize val))) (append! attr-parts (serialize val)))
(set! skip true) (set! skip true)
(set! i (inc i))) (set! i (inc i)))
(let ((val (aser arg env))) (let ((val (aser arg env)))
@@ -152,12 +164,24 @@
(for-each (for-each
(fn (item) (fn (item)
(when (not (nil? item)) (when (not (nil? item))
(append! parts (serialize item)))) (append! child-parts (serialize item))))
val) val)
(append! parts (serialize val)))) (append! child-parts (serialize val))))
(set! i (inc i)))))) (set! i (inc i))))))
args) args)
(str "(" (join " " parts) ")")))) ;; Collect emitted spread attrs — goes after explicit attrs, before children
(for-each
(fn (spread-dict)
(for-each
(fn (k)
(let ((v (dict-get spread-dict k)))
(append! attr-parts (str ":" k))
(append! attr-parts (serialize v))))
(keys spread-dict)))
(emitted "element-attrs"))
(provide-pop! "element-attrs")
(let ((parts (concat (list name) attr-parts child-parts)))
(str "(" (join " " parts) ")")))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -171,7 +195,7 @@
"defhandler" "defpage" "defquery" "defaction" "defrelation" "defhandler" "defpage" "defquery" "defaction" "defrelation"
"begin" "do" "quote" "quasiquote" "begin" "do" "quote" "quasiquote"
"->" "set!" "letrec" "dynamic-wind" "defisland" "->" "set!" "letrec" "dynamic-wind" "defisland"
"deftype" "defeffect")) "deftype" "defeffect" "provide"))
(define HO_FORM_NAMES (define HO_FORM_NAMES
(list "map" "map-indexed" "filter" "reduce" (list "map" "map-indexed" "filter" "reduce"
@@ -309,6 +333,17 @@
(= name "deftype") (= name "defeffect")) (= name "deftype") (= name "defeffect"))
(do (trampoline (eval-expr expr env)) nil) (do (trampoline (eval-expr expr env)) nil)
;; provide — render-time dynamic scope
(= name "provide")
(let ((prov-name (trampoline (eval-expr (first args) env)))
(prov-val (trampoline (eval-expr (nth args 1) env)))
(result nil))
(provide-push! prov-name prov-val)
(for-each (fn (body) (set! result (aser body env)))
(slice args 2))
(provide-pop! prov-name)
result)
;; Everything else — evaluate normally ;; Everything else — evaluate normally
:else :else
(trampoline (eval-expr expr env)))))) (trampoline (eval-expr expr env))))))

View File

@@ -87,7 +87,8 @@
;; Process sx- attributes, hydrate data-sx and islands ;; Process sx- attributes, hydrate data-sx and islands
(process-elements el) (process-elements el)
(sx-hydrate-elements el) (sx-hydrate-elements el)
(sx-hydrate-islands el)))))) (sx-hydrate-islands el)
(flush-cssx-to-dom))))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -119,6 +120,7 @@
(process-elements el) (process-elements el)
(sx-hydrate-elements el) (sx-hydrate-elements el)
(sx-hydrate-islands el) (sx-hydrate-islands el)
(flush-cssx-to-dom)
(dom-dispatch el "sx:resolved" {:id id}))) (dom-dispatch el "sx:resolved" {:id id})))
(log-warn (str "resolveSuspense: no element for id=" id)))))) (log-warn (str "resolveSuspense: no element for id=" id))))))
@@ -415,6 +417,32 @@
(for-each dispose-island to-dispose)))))))) (for-each dispose-island to-dispose))))))))
;; --------------------------------------------------------------------------
;; CSSX live flush — inject collected CSS rules into the DOM
;; --------------------------------------------------------------------------
;;
;; ~cssx/tw collects CSS rules via collect!("cssx" ...) during rendering.
;; On the server, ~cssx/flush emits a batch <style> tag. On the client,
;; islands render independently and no batch flush runs. This function
;; injects any unflushed rules into a persistent <style> element in <head>.
;; Called after hydration (boot + post-swap) to cover all render paths.
(define flush-cssx-to-dom :effects [mutation io]
(fn ()
(let ((rules (collected "cssx")))
(when (not (empty? rules))
(let ((style (or (dom-query "#sx-cssx-live")
(let ((s (dom-create-element "style" nil)))
(dom-set-attr s "id" "sx-cssx-live")
(dom-set-attr s "data-cssx" "")
(dom-append-to-head s)
s))))
(dom-set-prop style "textContent"
(str (or (dom-get-prop style "textContent") "")
(join "" rules))))
(clear-collected! "cssx")))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; Full boot sequence ;; Full boot sequence
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -436,6 +464,7 @@
(process-sx-scripts nil) (process-sx-scripts nil)
(sx-hydrate-elements nil) (sx-hydrate-elements nil)
(sx-hydrate-islands nil) (sx-hydrate-islands nil)
(flush-cssx-to-dom)
(process-elements nil)))) (process-elements nil))))

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