Compare commits
72 Commits
da1ca6009a
...
6ca46bb295
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ca46bb295 | |||
| e1a5e3eb89 | |||
| aef990735f | |||
| 04d3b2ecaf | |||
| c4a999d0d0 | |||
| 2de4ba8c57 | |||
| ee969a343c | |||
| 400d6d4086 | |||
| dbf16929fa | |||
| 859aad4333 | |||
| c95e320825 | |||
| 427dee13f0 | |||
| a7de0e9410 | |||
| 214963ea6a | |||
| 2fc391696c | |||
| 28a6560963 | |||
| cee0ca7667 | |||
| 98036b2292 | |||
| 6d0c0b2230 | |||
| 9d0bd3b0e7 | |||
| 2329533d1a | |||
| 085f959323 | |||
| fe911625e3 | |||
| 9806aec60c | |||
| 36b070f796 | |||
| ae6c6d06a7 | |||
| 846719908f | |||
| 301bb8e585 | |||
| d42972518a | |||
| 071869331f | |||
| 2fd64351d0 | |||
| 9096476402 | |||
| 0847824935 | |||
| b31eb393c4 | |||
| 2c97542ee8 | |||
| 04539675d8 | |||
| 1d1e7f30bb | |||
| 56dfff8299 | |||
| f52b9e880b | |||
| a0d78e44d5 | |||
| 9284a946ba | |||
| 11ea641f7b | |||
| c3430ade90 | |||
| 1f22f3fcd5 | |||
| 8100dc5fc9 | |||
| 5f6600f572 | |||
| ea2b71cfa3 | |||
| 41097eeef9 | |||
| c2efa192c5 | |||
| 100450772f | |||
| 7c969f9192 | |||
| bc1ea0128f | |||
| 0358b6ec9e | |||
| a2d8fb0f0f | |||
| cedff42d15 | |||
| 1324e984ef | |||
| 5f06e2e2cc | |||
| b9d85bd797 | |||
| 1dd2d73766 | |||
| 355f57a60b | |||
| c6a4a6f65c | |||
| 6186cd1c53 | |||
| 1647921895 | |||
| b0920a1121 | |||
| de80d921e9 | |||
| acd2fa6541 | |||
| b23e81730c | |||
| 7a1d1e9ea2 | |||
| 9f2f4377b9 | |||
| f759cd6688 | |||
| 2076e1805f | |||
| feecbb66ba |
@@ -7,6 +7,7 @@ on:
|
||||
env:
|
||||
REGISTRY: registry.rose-ash.com:5000
|
||||
APP_DIR: /root/rose-ash
|
||||
BUILD_DIR: /root/rose-ash-ci
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -33,23 +34,26 @@ jobs:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
run: |
|
||||
ssh "root@$DEPLOY_HOST" "
|
||||
cd ${{ env.APP_DIR }}
|
||||
|
||||
# Save current HEAD before updating
|
||||
OLD_HEAD=\$(git rev-parse HEAD 2>/dev/null || echo none)
|
||||
|
||||
git fetch origin ${{ github.ref_name }}
|
||||
# --- Build in isolated CI directory (never touch dev working tree) ---
|
||||
BUILD=${{ env.BUILD_DIR }}
|
||||
ORIGIN=\$(git -C ${{ env.APP_DIR }} remote get-url origin)
|
||||
if [ ! -d \"\$BUILD/.git\" ]; then
|
||||
git clone \"\$ORIGIN\" \"\$BUILD\"
|
||||
fi
|
||||
cd \"\$BUILD\"
|
||||
git fetch origin
|
||||
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
|
||||
if [ \"\$OLD_HEAD\" = \"none\" ] || [ \"\$OLD_HEAD\" = \"\$NEW_HEAD\" ]; then
|
||||
# First deploy or CI re-run on same commit — rebuild all
|
||||
if [ -z \"\$BEFORE\" ] || [ \"\$BEFORE\" = '0000000000000000000000000000000000000000' ] || ! git cat-file -e \"\$BEFORE\" 2>/dev/null; then
|
||||
# New branch, force push, or unreachable parent — rebuild all
|
||||
REBUILD_ALL=true
|
||||
else
|
||||
CHANGED=\$(git diff --name-only \$OLD_HEAD \$NEW_HEAD)
|
||||
CHANGED=\$(git diff --name-only \$BEFORE \$AFTER)
|
||||
if echo \"\$CHANGED\" | grep -q '^shared/'; then
|
||||
REBUILD_ALL=true
|
||||
fi
|
||||
@@ -86,8 +90,8 @@ jobs:
|
||||
|
||||
# Deploy swarm stacks only on main branch
|
||||
if [ '${{ github.ref_name }}' = 'main' ]; then
|
||||
source .env
|
||||
docker stack deploy -c docker-compose.yml rose-ash
|
||||
source ${{ env.APP_DIR }}/.env
|
||||
docker stack deploy --resolve-image always -c docker-compose.yml rose-ash
|
||||
echo 'Waiting for swarm services to update...'
|
||||
sleep 10
|
||||
docker stack services rose-ash
|
||||
@@ -99,17 +103,17 @@ jobs:
|
||||
fi
|
||||
if [ \"\$SX_REBUILT\" = true ]; then
|
||||
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
|
||||
docker stack services sx-web
|
||||
# Reload Caddy to pick up any Caddyfile changes
|
||||
docker service update --force caddy_caddy 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
|
||||
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...'
|
||||
docker compose -p rose-ash-dev -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
echo 'Dev stack deployed'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
shared/sx/.cache/
|
||||
.env
|
||||
node_modules/
|
||||
*.egg-info/
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
;; Auth page components (device auth — account-specific)
|
||||
;; 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
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
||||
error)))
|
||||
|
||||
(defcomp ~account-device-form (&key error (action :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"
|
||||
(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.")
|
||||
@@ -22,30 +22,30 @@
|
||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
"Authorize"))))
|
||||
|
||||
(defcomp ~account-device-approved ()
|
||||
(defcomp ~auth/device-approved ()
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
||||
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
||||
|
||||
;; Assembled auth page content — replaces Python _login_page_content etc.
|
||||
|
||||
(defcomp ~account-login-content (&key (error :as string?) (email :as string?))
|
||||
(~auth-login-form
|
||||
:error (when error (~auth-error-banner :error error))
|
||||
(defcomp ~auth/login-content (&key (error :as string?) (email :as string?))
|
||||
(~shared:auth/login-form
|
||||
:error (when error (~shared:auth/error-banner :error error))
|
||||
:action (url-for "auth.start_login")
|
||||
:csrf-token (csrf-token)
|
||||
:email (or email "")))
|
||||
|
||||
(defcomp ~account-device-content (&key (error :as string?) (code :as string?))
|
||||
(~account-device-form
|
||||
:error (when error (~account-device-error :error error))
|
||||
(defcomp ~auth/device-content (&key (error :as string?) (code :as string?))
|
||||
(~auth/device-form
|
||||
:error (when error (~auth/device-error :error error))
|
||||
:action (url-for "auth.device_submit")
|
||||
:csrf-token (csrf-token)
|
||||
:code (or code "")))
|
||||
|
||||
(defcomp ~account-check-email-content (&key (email :as string?) (email-error :as string?))
|
||||
(~auth-check-email
|
||||
(defcomp ~auth/check-email-content (&key (email :as string?) (email-error :as string?))
|
||||
(~shared:auth/check-email
|
||||
:email (escape (or email ""))
|
||||
:error (when email-error
|
||||
(~auth-check-email-error :error (escape email-error)))))
|
||||
(~shared:auth/check-email-error :error (escape email-error)))))
|
||||
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
;; Account dashboard components
|
||||
|
||||
(defcomp ~account-error-banner (&key (error :as string))
|
||||
(defcomp ~dashboard/error-banner (&key (error :as string))
|
||||
(when error
|
||||
(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm"
|
||||
error)))
|
||||
|
||||
(defcomp ~account-user-email (&key (email :as string))
|
||||
(defcomp ~dashboard/user-email (&key (email :as string))
|
||||
(when 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
|
||||
(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"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
||||
(button :type "submit"
|
||||
:class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
|
||||
(i :class "fa-solid fa-right-from-bracket text-xs") " Sign out")))
|
||||
|
||||
(defcomp ~account-label-item (&key (name :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"
|
||||
name))
|
||||
|
||||
(defcomp ~account-labels-section (&key items)
|
||||
(defcomp ~dashboard/labels-section (&key items)
|
||||
(when items
|
||||
(div
|
||||
(h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")
|
||||
(div :class "flex flex-wrap gap-2" items))))
|
||||
|
||||
(defcomp ~account-main-panel (&key error email name logout labels)
|
||||
(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 "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"
|
||||
error
|
||||
@@ -43,18 +43,18 @@
|
||||
labels)))
|
||||
|
||||
;; 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))
|
||||
(csrf (csrf-token)))
|
||||
(~account-main-panel
|
||||
:error (when error (~account-error-banner :error error))
|
||||
(~dashboard/main-panel
|
||||
:error (when error (~dashboard/error-banner :error error))
|
||||
:email (when (get user "email")
|
||||
(~account-user-email :email (get user "email")))
|
||||
(~dashboard/user-email :email (get user "email")))
|
||||
:name (when (get user "name")
|
||||
(~account-user-name :name (get user "name")))
|
||||
:logout (~account-logout-form :csrf-token csrf)
|
||||
(~dashboard/user-name :name (get user "name")))
|
||||
:logout (~dashboard/logout-form :csrf-token csrf)
|
||||
:labels (when (not (empty? (or (get user "labels") (list))))
|
||||
(~account-labels-section
|
||||
(~dashboard/labels-section
|
||||
:items (map (lambda (label)
|
||||
(~account-label-item :name (get label "name")))
|
||||
(~dashboard/label-item :name (get label "name")))
|
||||
(get user "labels")))))))
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
;; Registered via register_sx_layout("account", ...) in __init__.py.
|
||||
|
||||
;; Full page: root header + auth header row in header-child
|
||||
(defcomp ~account-layout-full ()
|
||||
(defcomp ~layouts/full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~auth-header-row-auto))))
|
||||
|
||||
;; OOB (HTMX): auth row + root header, both with oob=true
|
||||
(defcomp ~account-layout-oob ()
|
||||
(defcomp ~layouts/oob ()
|
||||
(<> (~auth-header-row-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; Mobile menu: auth section + root nav
|
||||
(defcomp ~account-layout-mobile ()
|
||||
(<> (~mobile-menu-section
|
||||
(defcomp ~layouts/mobile ()
|
||||
(<> (~shared:layout/mobile-menu-section
|
||||
:label "account" :href "/" :level 1 :colour "sky"
|
||||
:items (~auth-nav-items-auto))
|
||||
(~root-mobile-auto)))
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
;; Newsletter management components
|
||||
|
||||
(defcomp ~account-newsletter-desc (&key (description :as string))
|
||||
(defcomp ~newsletters/desc (&key (description :as string))
|
||||
(when 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"
|
||||
(button :sx-post url :sx-headers hdrs :sx-target target :sx-swap "outerHTML"
|
||||
:class cls :role "switch" :aria-checked checked
|
||||
(span :class knob-cls))))
|
||||
|
||||
|
||||
(defcomp ~account-newsletter-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 "min-w-0 flex-1"
|
||||
(p :class "text-sm font-medium text-stone-800" name)
|
||||
desc)
|
||||
(div :class "ml-4 flex-shrink-0" toggle)))
|
||||
|
||||
(defcomp ~account-newsletter-list (&key items)
|
||||
(defcomp ~newsletters/list (&key 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."))
|
||||
|
||||
(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 "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")
|
||||
@@ -32,12 +32,12 @@
|
||||
|
||||
;; Assembled newsletters content — replaces Python _newsletters_panel_sx
|
||||
;; 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)))
|
||||
(if (empty? newsletter-list)
|
||||
(~account-newsletter-empty)
|
||||
(~account-newsletters-panel
|
||||
:list (~account-newsletter-list
|
||||
(~newsletters/empty)
|
||||
(~newsletters/panel
|
||||
:list (~newsletters/list
|
||||
:items (map (lambda (item)
|
||||
(let* ((nl (get item "newsletter"))
|
||||
(un (get item "un"))
|
||||
@@ -47,11 +47,11 @@
|
||||
(bg (if subscribed "bg-emerald-500" "bg-stone-300"))
|
||||
(translate (if subscribed "translate-x-6" "translate-x-1"))
|
||||
(checked (if subscribed "true" "false")))
|
||||
(~account-newsletter-item
|
||||
(~newsletters/item
|
||||
:name (get nl "name")
|
||||
:desc (when (get nl "description")
|
||||
(~account-newsletter-desc :description (get nl "description")))
|
||||
:toggle (~account-newsletter-toggle
|
||||
(~newsletters/desc :description (get nl "description")))
|
||||
:toggle (~newsletters/toggle
|
||||
:id (str "nl-" nid)
|
||||
:url toggle-url
|
||||
:hdrs {:X-CSRFToken csrf}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:path "/"
|
||||
:auth :login
|
||||
:layout :account
|
||||
:content (~account-dashboard-content))
|
||||
:content (~dashboard/content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Newsletters
|
||||
@@ -19,7 +19,7 @@
|
||||
:auth :login
|
||||
:layout :account
|
||||
:data (service "account-page" "newsletters-data")
|
||||
:content (~account-newsletters-content
|
||||
:content (~newsletters/content
|
||||
:newsletter-list newsletter-list
|
||||
:account-url account-url))
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ def _image(node: dict) -> str:
|
||||
parts.append(f':width "{_esc(width)}"')
|
||||
if href:
|
||||
parts.append(f':href "{_esc(href)}"')
|
||||
return "(~kg-image " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-image " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("gallery")
|
||||
@@ -282,14 +282,14 @@ def _gallery(node: dict) -> str:
|
||||
images_sx = "(list " + " ".join(rows) + ")"
|
||||
caption = node.get("caption", "")
|
||||
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")
|
||||
def _html_card(node: dict) -> str:
|
||||
raw = node.get("html", "")
|
||||
inner = html_to_sx(raw)
|
||||
return f"(~kg-html {inner})"
|
||||
return f"(~kg_cards/kg-html {inner})"
|
||||
|
||||
|
||||
@_converter("embed")
|
||||
@@ -299,7 +299,7 @@ def _embed(node: dict) -> str:
|
||||
parts = [f':html "{_esc(embed_html)}"']
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg-embed " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-embed " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("bookmark")
|
||||
@@ -330,7 +330,7 @@ def _bookmark(node: dict) -> str:
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
|
||||
return "(~kg-bookmark " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-bookmark " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("callout")
|
||||
@@ -344,7 +344,7 @@ def _callout(node: dict) -> str:
|
||||
parts.append(f':emoji "{_esc(emoji)}"')
|
||||
if inner:
|
||||
parts.append(f':content {inner}')
|
||||
return "(~kg-callout " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-callout " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("button")
|
||||
@@ -352,7 +352,7 @@ def _button(node: dict) -> str:
|
||||
text = node.get("buttonText", "")
|
||||
url = node.get("buttonUrl", "")
|
||||
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")
|
||||
@@ -360,7 +360,7 @@ def _toggle(node: dict) -> str:
|
||||
heading = node.get("heading", "")
|
||||
inner = _convert_children(node.get("children", []))
|
||||
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")
|
||||
@@ -380,7 +380,7 @@ def _audio(node: dict) -> str:
|
||||
parts.append(f':duration "{duration_str}"')
|
||||
if thumbnail:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
return "(~kg-audio " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-audio " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("video")
|
||||
@@ -400,7 +400,7 @@ def _video(node: dict) -> str:
|
||||
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||
if loop:
|
||||
parts.append(":loop true")
|
||||
return "(~kg-video " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-video " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("file")
|
||||
@@ -429,12 +429,12 @@ def _file(node: dict) -> str:
|
||||
parts.append(f':filesize "{size_str}"')
|
||||
if caption:
|
||||
parts.append(f":caption {html_to_sx(caption)}")
|
||||
return "(~kg-file " + " ".join(parts) + ")"
|
||||
return "(~kg_cards/kg-file " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
@_converter("paywall")
|
||||
def _paywall(_node: dict) -> str:
|
||||
return "(~kg-paywall)"
|
||||
return "(~kg_cards/kg-paywall)"
|
||||
|
||||
|
||||
@_converter("markdown")
|
||||
@@ -442,4 +442,4 @@ def _markdown(node: dict) -> str:
|
||||
md_text = node.get("markdown", "")
|
||||
rendered = mistune.html(md_text)
|
||||
inner = html_to_sx(rendered)
|
||||
return f"(~kg-md {inner})"
|
||||
return f"(~kg_cards/kg-md {inner})"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/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.
|
||||
|
||||
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
|
||||
posts that already have sx_content, overwriting the old output.
|
||||
|
||||
@@ -50,11 +50,11 @@ async def migrate(dry_run: bool = False) -> int:
|
||||
continue
|
||||
|
||||
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 "")
|
||||
markers = []
|
||||
if old_has_kg:
|
||||
markers.append("~kg-html")
|
||||
markers.append("~kg_cards/kg-html")
|
||||
if old_has_raw:
|
||||
markers.append("raw-caption")
|
||||
tag = f" [{', '.join(markers)}]" if markers else ""
|
||||
@@ -76,7 +76,7 @@ async def migrate(dry_run: bool = False) -> int:
|
||||
|
||||
def main():
|
||||
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",
|
||||
help="Preview changes without writing to database")
|
||||
|
||||
@@ -398,7 +398,7 @@ class BlogPageService:
|
||||
}
|
||||
|
||||
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", "")
|
||||
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
user_id = getattr(user, "id", None) if user else None
|
||||
|
||||
234
blog/sx/admin.sx
234
blog/sx/admin.sx
@@ -1,6 +1,6 @@
|
||||
;; 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 "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"
|
||||
@@ -8,21 +8,21 @@
|
||||
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
|
||||
(div :id "cache-status" :class "py-2"))))
|
||||
|
||||
(defcomp ~blog-snippets-panel (&key list)
|
||||
(defcomp ~admin/snippets-panel (&key list)
|
||||
(div :class "max-w-4xl mx-auto p-6"
|
||||
(div :class "mb-6 flex justify-between items-center"
|
||||
(h1 :class "text-3xl font-bold" "Snippets"))
|
||||
(div :id "snippets-list" list)))
|
||||
|
||||
(defcomp ~blog-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"
|
||||
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
|
||||
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))
|
||||
|
||||
(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-1 min-w-0"
|
||||
(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)
|
||||
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)))
|
||||
|
||||
(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 "mb-6 flex justify-end items-center"
|
||||
(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-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 "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
|
||||
img
|
||||
@@ -54,16 +54,16 @@
|
||||
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
|
||||
(i :class "fa fa-edit") " Edit")
|
||||
(~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
|
||||
: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)))
|
||||
|
||||
;; 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"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(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")
|
||||
(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"))
|
||||
|
||||
(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"
|
||||
: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"
|
||||
icon
|
||||
(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" (str "order: " sort-order))))
|
||||
|
||||
(defcomp ~blog-tag-groups-list (&key items)
|
||||
(defcomp ~admin/tag-groups-list (&key 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))
|
||||
|
||||
(defcomp ~blog-unassigned-tags (&key heading spans)
|
||||
(defcomp ~admin/unassigned-tags (&key heading spans)
|
||||
(div :class "border-t pt-4"
|
||||
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
|
||||
(div :class "flex flex-wrap gap-2" spans)))
|
||||
|
||||
(defcomp ~blog-tag-groups-main (&key form groups unassigned)
|
||||
(defcomp ~admin/tag-groups-main (&key form groups unassigned)
|
||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
|
||||
form groups unassigned))
|
||||
|
||||
;; Tag group edit
|
||||
|
||||
(defcomp ~blog-tag-checkbox (&key (tag-id :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"
|
||||
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
|
||||
img (span name)))
|
||||
|
||||
(defcomp ~blog-tag-checkbox-image (&key src)
|
||||
(defcomp ~admin/tag-checkbox-image (&key src)
|
||||
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-form (&key (save-url :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"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div :class "space-y-3"
|
||||
@@ -133,19 +133,19 @@
|
||||
(div :class "flex gap-3"
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
|
||||
|
||||
(defcomp ~blog-tag-group-delete-form (&key (delete-url :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"
|
||||
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
|
||||
(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"
|
||||
edit-form delete-form))
|
||||
|
||||
;; Data-driven snippets list (replaces Python _snippets_sx loop)
|
||||
(defcomp ~blog-snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
|
||||
(~blog-snippets-list
|
||||
(defcomp ~admin/snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
|
||||
(~admin/snippets-list
|
||||
:rows (<> (map (lambda (s)
|
||||
(let* ((s-id (get s "id"))
|
||||
(s-name (get s "name"))
|
||||
@@ -155,31 +155,31 @@
|
||||
(badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
|
||||
(extra (<>
|
||||
(when is-admin
|
||||
(~blog-snippet-visibility-select
|
||||
(~admin/snippet-visibility-select
|
||||
:patch-url (get s "patch_url")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:options (<>
|
||||
(~blog-snippet-option :value "private" :selected (= s-vis "private") :label "private")
|
||||
(~blog-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 "private" :selected (= s-vis "private") :label "private")
|
||||
(~admin/snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
|
||||
(~admin/snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
|
||||
:cls "text-sm border border-stone-300 rounded px-2 py-1"))
|
||||
(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?"
|
||||
:text (str "Delete \u201c" s-name "\u201d?")
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
|
||||
(~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)))
|
||||
(or snippets (list))))))
|
||||
|
||||
;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
|
||||
(defcomp ~blog-menu-items-from-data (&key items csrf)
|
||||
(~blog-menu-items-list
|
||||
(defcomp ~admin/menu-items-from-data (&key items csrf)
|
||||
(~admin/menu-items-list
|
||||
: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")))
|
||||
(~blog-menu-item-row
|
||||
(~admin/menu-item-row
|
||||
:img img :label (get item "label") :slug (get item "slug")
|
||||
:sort-order (get item "sort_order") :edit-url (get item "edit_url")
|
||||
:delete-url (get item "delete_url")
|
||||
@@ -188,38 +188,38 @@
|
||||
(or items (list))))))
|
||||
|
||||
;; 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)
|
||||
(~blog-tag-groups-main
|
||||
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
(defcomp ~admin/tag-groups-from-data (&key groups unassigned-tags csrf create-url)
|
||||
(~admin/tag-groups-main
|
||||
:form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
:groups (if (empty? (or groups (list)))
|
||||
(~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
|
||||
(~blog-tag-groups-list
|
||||
(~shared:misc/empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
|
||||
(~admin/tag-groups-list
|
||||
:items (<> (map (lambda (g)
|
||||
(let* ((icon (if (get g "feature_image")
|
||||
(~blog-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")))))
|
||||
(~blog-tag-group-li :icon icon :edit-href (get g "edit_href")
|
||||
(~admin/tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
|
||||
(~admin/tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
|
||||
(~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"))))
|
||||
groups))))
|
||||
:unassigned (when (not (empty? (or unassigned-tags (list))))
|
||||
(~blog-unassigned-tags
|
||||
(~admin/unassigned-tags
|
||||
:heading (str "Unassigned Tags (" (len unassigned-tags) ")")
|
||||
:spans (<> (map (lambda (t)
|
||||
(~blog-unassigned-tag :name (get t "name")))
|
||||
(~admin/unassigned-tag :name (get t "name")))
|
||||
unassigned-tags))))))
|
||||
|
||||
;; 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)
|
||||
(~blog-tag-checkbox
|
||||
(~admin/tag-checkbox
|
||||
: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")))
|
||||
(or tags (list)))))
|
||||
|
||||
;; 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"
|
||||
(style "
|
||||
.sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
|
||||
@@ -239,18 +239,18 @@
|
||||
")
|
||||
sections))
|
||||
|
||||
(defcomp ~blog-preview-section (&key title content)
|
||||
(defcomp ~admin/preview-section (&key title content)
|
||||
(details :class "border rounded bg-white"
|
||||
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
|
||||
(div :class "p-4 overflow-x-auto text-xs" content)))
|
||||
|
||||
(defcomp ~blog-preview-rendered (&key html)
|
||||
(defcomp ~admin/preview-rendered (&key 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."))
|
||||
|
||||
(defcomp ~blog-admin-placeholder ()
|
||||
(defcomp ~admin/placeholder ()
|
||||
(div :class "pb-8"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -258,12 +258,12 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Snippets — receives serialized snippet dicts from service
|
||||
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
|
||||
(~blog-snippets-panel
|
||||
(defcomp ~admin/snippets-content (&key snippets is-admin csrf)
|
||||
(~admin/snippets-panel
|
||||
: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.")
|
||||
(~blog-snippets-list
|
||||
(~admin/snippets-list
|
||||
:rows (map (lambda (s)
|
||||
(let* ((badge-colours (dict
|
||||
"private" "bg-stone-200 text-stone-700"
|
||||
@@ -274,19 +274,19 @@
|
||||
(name (get s "name"))
|
||||
(owner (get s "owner"))
|
||||
(can-delete (get s "can_delete")))
|
||||
(~blog-snippet-row
|
||||
(~admin/snippet-row
|
||||
:name name :owner owner :badge-cls badge-cls :visibility vis
|
||||
:extra (<>
|
||||
(when is-admin
|
||||
(~blog-snippet-visibility-select
|
||||
(~admin/snippet-visibility-select
|
||||
:patch-url (get s "patch_url")
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:options (<>
|
||||
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
|
||||
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
||||
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
|
||||
(~admin/snippet-option :value "private" :selected (= vis "private") :label "private")
|
||||
(~admin/snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
||||
(~admin/snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
|
||||
(when can-delete
|
||||
(~delete-btn
|
||||
(~shared:misc/delete-btn
|
||||
:url (get s "delete_url")
|
||||
:trigger-target "#snippets-list"
|
||||
:title "Delete snippet?"
|
||||
@@ -296,16 +296,16 @@
|
||||
(or snippets (list)))))))
|
||||
|
||||
;; Menu Items — receives serialized menu item dicts from service
|
||||
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
|
||||
(~blog-menu-items-panel
|
||||
(defcomp ~admin/menu-items-content (&key menu-items new-url csrf)
|
||||
(~admin/menu-items-panel
|
||||
:new-url new-url
|
||||
: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!")
|
||||
(~blog-menu-items-list
|
||||
(~admin/menu-items-list
|
||||
:rows (map (lambda (mi)
|
||||
(~blog-menu-item-row
|
||||
:img (~img-or-placeholder
|
||||
(~admin/menu-item-row
|
||||
:img (~shared:misc/img-or-placeholder
|
||||
:src (get mi "feature_image") :alt (get mi "label")
|
||||
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
||||
:label (get mi "label")
|
||||
@@ -318,23 +318,23 @@
|
||||
(or menu-items (list)))))))
|
||||
|
||||
;; Tag Groups — receives serialized tag group data from service
|
||||
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
|
||||
(~blog-tag-groups-main
|
||||
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
(defcomp ~admin/tag-groups-content (&key groups unassigned-tags create-url csrf)
|
||||
(~admin/tag-groups-main
|
||||
:form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
|
||||
:groups (if (empty? (or groups (list)))
|
||||
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
|
||||
(~blog-tag-groups-list
|
||||
(~shared:misc/empty-state :icon "fa fa-tags" :message "No tag groups yet.")
|
||||
(~admin/tag-groups-list
|
||||
:items (map (lambda (g)
|
||||
(let* ((fi (get g "feature_image"))
|
||||
(colour (get g "colour"))
|
||||
(name (get g "name"))
|
||||
(initial (slice (or name "?") 0 1))
|
||||
(icon (if fi
|
||||
(~blog-tag-group-icon-image :src fi :name name)
|
||||
(~blog-tag-group-icon-color
|
||||
(~admin/tag-group-icon-image :src fi :name name)
|
||||
(~admin/tag-group-icon-color
|
||||
:style (if colour (str "background:" colour) "background:#e7e5e4")
|
||||
:initial initial))))
|
||||
(~blog-tag-group-li
|
||||
(~admin/tag-group-li
|
||||
:icon icon
|
||||
:edit-href (get g "edit_href")
|
||||
:name name
|
||||
@@ -342,57 +342,57 @@
|
||||
:sort-order (or (get g "sort_order") 0))))
|
||||
(or groups (list)))))
|
||||
:unassigned (when (not (empty? (or unassigned-tags (list))))
|
||||
(~blog-unassigned-tags
|
||||
(~admin/unassigned-tags
|
||||
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
|
||||
:spans (map (lambda (t)
|
||||
(~blog-unassigned-tag :name (get t "name")))
|
||||
(~admin/unassigned-tag :name (get t "name")))
|
||||
(or unassigned-tags (list)))))))
|
||||
|
||||
;; Tag Group Edit — receives serialized tag group + tags from service
|
||||
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
|
||||
(~blog-tag-group-edit-main
|
||||
:edit-form (~blog-tag-group-edit-form
|
||||
(defcomp ~admin/tag-group-edit-content (&key group all-tags save-url delete-url csrf)
|
||||
(~admin/tag-group-edit-main
|
||||
:edit-form (~admin/tag-group-edit-form
|
||||
:save-url save-url :csrf csrf
|
||||
:name (get group "name")
|
||||
:colour (get group "colour")
|
||||
:sort-order (get group "sort_order")
|
||||
:feature-image (get group "feature_image")
|
||||
:tags (map (lambda (t)
|
||||
(~blog-tag-checkbox
|
||||
(~admin/tag-checkbox
|
||||
:tag-id (get t "id")
|
||||
:checked (get t "checked")
|
||||
:img (when (get t "feature_image")
|
||||
(~blog-tag-checkbox-image :src (get t "feature_image")))
|
||||
(~admin/tag-checkbox-image :src (get t "feature_image")))
|
||||
:name (get t "name")))
|
||||
(or all-tags (list))))
|
||||
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
|
||||
:delete-form (~admin/tag-group-delete-form :delete-url delete-url :csrf csrf)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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)))
|
||||
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
|
||||
(~blog-preview-empty)
|
||||
(~blog-preview-panel :sections
|
||||
(~admin/preview-empty)
|
||||
(~admin/preview-panel :sections
|
||||
(<>
|
||||
(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
|
||||
(~blog-preview-section :title "Lexical JSON" :content json-pretty))
|
||||
(~admin/preview-section :title "Lexical JSON" :content json-pretty))
|
||||
(when sx-rendered
|
||||
(~blog-preview-section :title "SX Rendered"
|
||||
:content (~blog-preview-rendered :html sx-rendered)))
|
||||
(~admin/preview-section :title "SX Rendered"
|
||||
:content (~admin/preview-rendered :html sx-rendered)))
|
||||
(when lex-rendered
|
||||
(~blog-preview-section :title "Lexical Rendered"
|
||||
:content (~blog-preview-rendered :html lex-rendered))))))))
|
||||
(~admin/preview-section :title "Lexical Rendered"
|
||||
:content (~admin/preview-rendered :html lex-rendered))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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")
|
||||
(span :class "text-neutral-400" "\u2014")
|
||||
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
|
||||
@@ -400,7 +400,7 @@
|
||||
(code 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"
|
||||
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
|
||||
(thead :class "bg-neutral-50/70"
|
||||
@@ -411,10 +411,10 @@
|
||||
(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 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)))))))
|
||||
|
||||
(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"
|
||||
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
|
||||
(td :class "px-2 py-1 align-top"
|
||||
@@ -422,11 +422,11 @@
|
||||
(code summary))
|
||||
(when children
|
||||
(div :class "mt-2 pl-3 border-l border-neutral-200"
|
||||
(~blog-data-model-content
|
||||
(~admin/data-model-content
|
||||
:columns (get children "columns")
|
||||
: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 "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
|
||||
"Relationship: " (span :class "font-semibold" name)
|
||||
@@ -448,7 +448,7 @@
|
||||
(th :class "px-2 py-1 text-left" "Summary")))
|
||||
(tbody
|
||||
(map (lambda (item)
|
||||
(~blog-data-relationship-item
|
||||
(~admin/data-relationship-item
|
||||
:index (get item "index")
|
||||
:summary (get item "summary")
|
||||
:children (get item "children")))
|
||||
@@ -459,17 +459,17 @@
|
||||
(code (get value "summary")))
|
||||
(when (get value "children")
|
||||
(div :class "pl-3 border-l border-neutral-200"
|
||||
(~blog-data-model-content
|
||||
(~admin/data-model-content
|
||||
:columns (get (get value "children") "columns")
|
||||
: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"
|
||||
(~blog-data-scalar-table :columns columns)
|
||||
(~admin/data-scalar-table :columns columns)
|
||||
(when (not (empty? (or relationships (list))))
|
||||
(div :class "space-y-3"
|
||||
(map (lambda (rel)
|
||||
(~blog-data-relationship
|
||||
(~admin/data-relationship
|
||||
:name (get rel "name")
|
||||
:cardinality (get rel "cardinality")
|
||||
:class-name (get rel "class_name")
|
||||
@@ -477,13 +477,13 @@
|
||||
:value (get rel "value")))
|
||||
relationships)))))
|
||||
|
||||
(defcomp ~blog-data-table-content (&key tablename model-data)
|
||||
(defcomp ~admin/data-table-content (&key tablename 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"
|
||||
(div :class "mb-6 text-sm text-neutral-500"
|
||||
"Model: " (code "Post") " \u2022 Table: " (code tablename))
|
||||
(~blog-data-model-content
|
||||
(~admin/data-model-content
|
||||
:columns (get model-data "columns")
|
||||
:relationships (get model-data "relationships")))))
|
||||
|
||||
@@ -491,7 +491,7 @@
|
||||
;; 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"
|
||||
(span :class "truncate flex-1" name)
|
||||
(button :type "button" :class "flex-shrink-0 hover:text-red-600"
|
||||
@@ -505,7 +505,7 @@
|
||||
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
|
||||
(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"
|
||||
: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?"
|
||||
@@ -518,7 +518,7 @@
|
||||
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
|
||||
(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
|
||||
next-month-url next-year-url
|
||||
weekday-names days csrf)
|
||||
@@ -553,9 +553,9 @@
|
||||
(div :class "space-y-0.5"
|
||||
(map (lambda (e)
|
||||
(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)
|
||||
(~blog-cal-entry-unassociated
|
||||
(~admin/cal-entry-unassociated
|
||||
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
|
||||
entries))))))
|
||||
(or days (list))))))))
|
||||
@@ -564,15 +564,15 @@
|
||||
;; 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)))
|
||||
(cal-list (or calendars (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")
|
||||
(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)
|
||||
(~blog-nav-entries-empty)
|
||||
(~scroll-nav-wrapper
|
||||
(~shared:nav/blog-nav-entries-empty)
|
||||
(~shared:misc/scroll-nav-wrapper
|
||||
:wrapper-id "entries-calendars-nav-wrapper"
|
||||
:container-id "associated-items-container"
|
||||
:arrow-cls "entries-nav-arrow"
|
||||
@@ -581,12 +581,12 @@
|
||||
:right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
||||
:items (<>
|
||||
(map (lambda (e)
|
||||
(~calendar-entry-nav
|
||||
(~shared:navigation/calendar-entry-nav
|
||||
:href (get e "href") :nav-class nav-cls
|
||||
:name (get e "name") :date-str (get e "date_str")))
|
||||
entry-list)
|
||||
(map (lambda (c)
|
||||
(~blog-nav-calendar-item
|
||||
(~shared:nav/blog-nav-calendar-item
|
||||
:href (get c "href") :nav-cls nav-cls
|
||||
:name (get c "name")))
|
||||
cal-list))
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
;; 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"
|
||||
(~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"
|
||||
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
|
||||
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
|
||||
|
||||
(defcomp ~blog-published-status (&key (timestamp :as string))
|
||||
(defcomp ~cards/published-status (&key (timestamp :as string))
|
||||
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
|
||||
|
||||
;; 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
|
||||
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")
|
||||
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial)))
|
||||
|
||||
(defcomp ~blog-tag-item (&key src name initial)
|
||||
(defcomp ~cards/tag-item (&key src name initial)
|
||||
(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))))
|
||||
|
||||
;; 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)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))
|
||||
(map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors)))))))
|
||||
|
||||
;; Author components
|
||||
(defcomp ~blog-author-item (&key image name)
|
||||
(defcomp ~cards/author-item (&key image name)
|
||||
(li :class "flex items-center gap-1"
|
||||
(when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover"))
|
||||
(span :class "text-stone-700" name)))
|
||||
|
||||
;; Card — accepts pure data
|
||||
(defcomp ~blog-card (&key (slug :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?)
|
||||
status (is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?)
|
||||
(liked :as boolean) (like-url :as string?) (csrf-token :as string?)
|
||||
@@ -53,7 +53,7 @@
|
||||
(tags :as list?) (authors :as list?) widget)
|
||||
(article :class "border-b pb-6 last:border-b-0 relative"
|
||||
(when has-like
|
||||
(~blog-like-button
|
||||
(~cards/like-button
|
||||
:like-url like-url
|
||||
:hx-headers {:X-CSRFToken csrf-token}
|
||||
:heart (if liked "❤️" "🤍")))
|
||||
@@ -63,8 +63,8 @@
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(if is-draft
|
||||
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~blog-published-status :timestamp status-timestamp))))
|
||||
(~cards/draft-status :publish-requested publish-requested :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 excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
|
||||
widget
|
||||
@@ -73,14 +73,14 @@
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
(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?)
|
||||
(excerpt :as string?) (tags :as list?) (authors :as list?))
|
||||
(article :class "relative"
|
||||
@@ -91,33 +91,33 @@
|
||||
(div :class "p-3 text-center"
|
||||
(h2 :class "text-lg font-bold text-stone-900" title)
|
||||
(if is-draft
|
||||
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
|
||||
(when status-timestamp (~blog-published-status :timestamp status-timestamp)))
|
||||
(~cards/draft-status :publish-requested publish-requested :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 (or tags authors)
|
||||
(div :class "flex flex-row justify-center gap-3"
|
||||
(when tags
|
||||
(div :class "mt-4 flex items-center gap-2" (div "in")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
|
||||
(div)
|
||||
(when authors
|
||||
(div :class "mt-4 flex items-center gap-2" (div "by")
|
||||
(ul :class "flex flex-wrap gap-2 text-sm"
|
||||
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
|
||||
(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)
|
||||
(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)
|
||||
(if (= view "tile")
|
||||
(~blog-card-tile
|
||||
(~cards/tile
|
||||
:href (get p "href") :hx-select (get p "hx_select")
|
||||
:feature-image (get p "feature_image") :title (get p "title")
|
||||
:is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
|
||||
:status-timestamp (get p "status_timestamp")
|
||||
: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")
|
||||
:title (get p "title") :feature-image (get p "feature_image")
|
||||
:excerpt (get p "excerpt") :is-draft (get p "is_draft")
|
||||
@@ -131,10 +131,10 @@
|
||||
sentinel))
|
||||
|
||||
;; 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)
|
||||
(~blog-page-card
|
||||
(~cards/page-card
|
||||
:href (get pg "href") :hx-select (get pg "hx_select")
|
||||
:title (get pg "title")
|
||||
:has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
|
||||
@@ -143,21 +143,21 @@
|
||||
(or pages (list)))
|
||||
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"
|
||||
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
|
||||
(i :class "fa fa-calendar mr-1") "Calendar"))
|
||||
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
|
||||
(i :class "fa fa-shopping-bag mr-1") "Market"))))
|
||||
|
||||
(defcomp ~blog-page-card (&key (href :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"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||
(header :class "mb-2 text-center"
|
||||
(h2 :class "text-4xl font-bold text-stone-900" title)
|
||||
(~blog-page-badges :has-calendar has-calendar :has-market has-market)
|
||||
(when pub-timestamp (~blog-published-status :timestamp pub-timestamp)))
|
||||
(~cards/page-badges :has-calendar has-calendar :has-market has-market)
|
||||
(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 excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
;; 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"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
|
||||
(i :class "fa fa-pencil mr-1") " Edit"))
|
||||
|
||||
(defcomp ~blog-detail-draft (&key publish-requested edit)
|
||||
(defcomp ~detail/draft (&key publish-requested edit)
|
||||
(div :class "flex items-center justify-center gap-2 mb-3"
|
||||
(span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")
|
||||
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
||||
edit))
|
||||
|
||||
(defcomp ~blog-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"
|
||||
: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"
|
||||
(~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))
|
||||
|
||||
(defcomp ~blog-detail-chrome (&key like excerpt at-bar)
|
||||
(defcomp ~detail/chrome (&key like excerpt at-bar)
|
||||
(<> like
|
||||
excerpt
|
||||
(div :class "hidden md:block" at-bar)))
|
||||
|
||||
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content sx-content)
|
||||
(defcomp ~detail/main (&key draft chrome feature-image html-content sx-content)
|
||||
(<> (article :class "relative"
|
||||
draft
|
||||
chrome
|
||||
@@ -43,34 +43,34 @@
|
||||
;; 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?)
|
||||
(custom-excerpt :as string?) (tags :as list?) (authors :as list?)
|
||||
(feature-image :as string?) (html-content :as string?) (sx-content :as string?))
|
||||
(let* ((hx-select "#main-panel")
|
||||
(draft-sx (when is-draft
|
||||
(~blog-detail-draft
|
||||
(~detail/draft
|
||||
:publish-requested publish-requested
|
||||
: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)
|
||||
(~blog-detail-chrome
|
||||
(~detail/chrome
|
||||
:like (when has-user
|
||||
(~blog-detail-like
|
||||
(~detail/like
|
||||
:like-url like-url
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:heart (if liked "❤️" "🤍")))
|
||||
:excerpt (when (not (= custom-excerpt ""))
|
||||
(~blog-detail-excerpt :excerpt custom-excerpt))
|
||||
:at-bar (~blog-at-bar :tags tags :authors authors)))))
|
||||
(~blog-detail-main
|
||||
(~detail/excerpt :excerpt custom-excerpt))
|
||||
:at-bar (~cards/at-bar :tags tags :authors authors)))))
|
||||
(~detail/main
|
||||
:draft draft-sx
|
||||
:chrome chrome-sx
|
||||
:feature-image feature-image
|
||||
:html-content html-content
|
||||
:sx-content sx-content)))
|
||||
|
||||
(defcomp ~blog-meta (&key (robots :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)
|
||||
(title page-title)
|
||||
@@ -86,7 +86,7 @@
|
||||
(meta :name "twitter:description" :content desc)
|
||||
(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"
|
||||
(if sx-content
|
||||
(div :class "blog-content p-2" sx-content)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
;; 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"
|
||||
(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]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(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))))
|
||||
|
||||
;; 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?)
|
||||
(sx-content-val :as string?) (lexical-json :as string?)
|
||||
(has-sx :as boolean) (title-placeholder :as string)
|
||||
@@ -135,7 +135,7 @@
|
||||
(when footer-extra footer-extra)))))
|
||||
|
||||
;; Publish-mode show/hide script for edit form
|
||||
(defcomp ~blog-editor-publish-js (&key already-emailed)
|
||||
(defcomp ~editor/publish-js (&key already-emailed)
|
||||
(script
|
||||
"(function() {"
|
||||
" var statusSel = document.getElementById('status-select');"
|
||||
@@ -153,20 +153,20 @@
|
||||
" sync();"
|
||||
"})();"))
|
||||
|
||||
(defcomp ~blog-editor-styles (&key (css-href :as string))
|
||||
(defcomp ~editor/styles (&key (css-href :as string))
|
||||
(<> (link :rel "stylesheet" :href css-href)
|
||||
(style
|
||||
"#lexical-editor { display: flow-root; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
|
||||
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
|
||||
|
||||
(defcomp ~blog-editor-scripts (&key (js-src :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)
|
||||
(when sx-editor-js-src (script :src sx-editor-js-src))
|
||||
(script init-js)))
|
||||
|
||||
;; SX editor styles — comprehensive CSS for the Koenig-style block editor
|
||||
(defcomp ~sx-editor-styles ()
|
||||
(defcomp ~editor/sx-editor-styles ()
|
||||
(style
|
||||
;; Editor container
|
||||
".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)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(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
|
||||
save-error)
|
||||
(~blog-editor-panel :parts
|
||||
(~layouts/editor-panel :parts
|
||||
(<>
|
||||
(when save-error (~blog-editor-error :error save-error))
|
||||
(~blog-editor-form :csrf csrf :title-placeholder title-placeholder
|
||||
(when save-error (~editor/error :error save-error))
|
||||
(~editor/form :csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label)
|
||||
(~blog-editor-styles :css-href css-href)
|
||||
(~sx-editor-styles)
|
||||
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
(~editor/styles :css-href css-href)
|
||||
(~editor/sx-editor-styles)
|
||||
(~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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
|
||||
sx-content-val lexical-json has-sx
|
||||
title-placeholder status already-emailed
|
||||
newsletter-options footer-extra
|
||||
css-href js-src sx-editor-js-src init-js
|
||||
save-error)
|
||||
(~blog-editor-panel :parts
|
||||
(~layouts/editor-panel :parts
|
||||
(<>
|
||||
(when save-error (~blog-editor-error :error save-error))
|
||||
(~blog-editor-edit-form
|
||||
(when save-error (~editor/error :error save-error))
|
||||
(~editor/edit-form
|
||||
:csrf csrf :updated-at updated-at
|
||||
:title-val title-val :excerpt-val excerpt-val
|
||||
:feature-image feature-image :feature-image-caption feature-image-caption
|
||||
@@ -343,8 +343,8 @@
|
||||
:has-sx has-sx :title-placeholder title-placeholder
|
||||
:status status :already-emailed already-emailed
|
||||
:newsletter-options newsletter-options :footer-extra footer-extra)
|
||||
(~blog-editor-publish-js :already-emailed already-emailed)
|
||||
(~blog-editor-styles :css-href css-href)
|
||||
(~sx-editor-styles)
|
||||
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
(~editor/publish-js :already-emailed already-emailed)
|
||||
(~editor/styles :css-href css-href)
|
||||
(~editor/sx-editor-styles)
|
||||
(~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))))
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
;; 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"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class icon-class) label))
|
||||
|
||||
(defcomp ~blog-drafts-button (&key (href :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"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count)
|
||||
(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"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
|
||||
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
|
||||
|
||||
(defcomp ~blog-action-buttons-wrapper (&key inner)
|
||||
(defcomp ~filters/action-buttons-wrapper (&key inner)
|
||||
(div :class "flex flex-wrap gap-2 px-4 py-3" inner))
|
||||
|
||||
(defcomp ~blog-filter-any-topic (&key cls hx-select)
|
||||
(defcomp ~filters/any-topic (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded border " cls)
|
||||
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" "Any Topic")))
|
||||
|
||||
(defcomp ~blog-filter-group-icon-image (&key src name)
|
||||
(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"))
|
||||
|
||||
(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))
|
||||
|
||||
(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)
|
||||
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -40,19 +40,19 @@
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
|
||||
(defcomp ~blog-filter-nav (&key items)
|
||||
(defcomp ~filters/nav (&key items)
|
||||
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
|
||||
(ul :class "divide-y flex flex-col gap-3" items)))
|
||||
|
||||
(defcomp ~blog-filter-any-author (&key cls hx-select)
|
||||
(defcomp ~filters/any-author (&key cls hx-select)
|
||||
(li (a :class (str "px-3 py-1 rounded " cls)
|
||||
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true" "Any author")))
|
||||
|
||||
(defcomp ~blog-filter-author-icon (&key src name)
|
||||
(defcomp ~filters/author-icon (&key src name)
|
||||
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
|
||||
|
||||
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon name count)
|
||||
(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)
|
||||
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -61,41 +61,41 @@
|
||||
(span :class "flex-1")
|
||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
|
||||
|
||||
(defcomp ~blog-filter-summary (&key (text :as string))
|
||||
(defcomp ~filters/summary (&key (text :as string))
|
||||
(span :class "text-sm text-stone-600" text))
|
||||
|
||||
;; 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))))
|
||||
(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 (<>
|
||||
(~blog-filter-any-topic :cls any-cls :hx-select hx-select)
|
||||
(~filters/any-topic :cls any-cls :hx-select hx-select)
|
||||
(map (lambda (g)
|
||||
(let* ((slug (get g "slug"))
|
||||
(name (get g "name"))
|
||||
(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"))
|
||||
(icon (if (get g "feature_image")
|
||||
(~blog-filter-group-icon-image :src (get g "feature_image") :name name)
|
||||
(~blog-filter-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-icon-image :src (get g "feature_image") :name name)
|
||||
(~filters/group-icon-color :style (get g "style") :initial (get g "initial")))))
|
||||
(~filters/group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
|
||||
:icon icon :name name :count (get g "count"))))
|
||||
(or groups (list)))))))
|
||||
|
||||
;; 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))))
|
||||
(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 (<>
|
||||
(~blog-filter-any-author :cls any-cls :hx-select hx-select)
|
||||
(~filters/any-author :cls any-cls :hx-select hx-select)
|
||||
(map (lambda (a)
|
||||
(let* ((slug (get a "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"))
|
||||
(icon (when (get a "profile_image")
|
||||
(~blog-filter-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-icon :src (get a "profile_image") :name (get a "name")))))
|
||||
(~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"))))
|
||||
(or authors (list)))))))
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
(let ((post (query "blog" "post-by-slug" :slug (trim s))))
|
||||
(when post
|
||||
(<> (str "<!-- fragment:" (trim s) " -->")
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
@@ -22,7 +22,7 @@
|
||||
(when slug
|
||||
(let ((post (query "blog" "post-by-slug" :slug slug)))
|
||||
(when post
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
|
||||
@@ -30,25 +30,25 @@
|
||||
(app-url "blog" (str "/" item-slug "/"))))
|
||||
(selected (or (= item-slug (or first-seg ""))
|
||||
(= item-slug app))))
|
||||
(~blog-nav-item-link
|
||||
(~shared:nav/blog-nav-item-link
|
||||
:href href
|
||||
:hx-get href
|
||||
:selected (if selected "true" "false")
|
||||
:nav-cls nav-cls
|
||||
:img (~img-or-placeholder
|
||||
:img (~shared:misc/img-or-placeholder
|
||||
:src (get item "feature_image")
|
||||
:alt (or (get item "label") item-slug)
|
||||
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
:label (or (get item "label") item-slug)))) items)
|
||||
|
||||
;; Hardcoded artdag link
|
||||
(~blog-nav-item-link
|
||||
(~shared:nav/blog-nav-item-link
|
||||
:href (app-url "artdag" "/")
|
||||
:hx-get (app-url "artdag" "/")
|
||||
:selected (if (or (= "artdag" (or first-seg ""))
|
||||
(= "artdag" app)) "true" "false")
|
||||
:nav-cls nav-cls
|
||||
:img (~img-or-placeholder
|
||||
:img (~shared:misc/img-or-placeholder
|
||||
:src nil :alt "art-dag"
|
||||
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||
:label "art-dag")))
|
||||
@@ -69,8 +69,8 @@
|
||||
(right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200")))
|
||||
|
||||
(if (empty? items)
|
||||
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~scroll-nav-wrapper
|
||||
(~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~shared:misc/scroll-nav-wrapper
|
||||
:wrapper-id "menu-items-nav-wrapper"
|
||||
:container-id cid
|
||||
:arrow-cls arrow-cls
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
;; 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"
|
||||
: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"))
|
||||
|
||||
(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"
|
||||
(a :href href
|
||||
:aria-selected (when is-selected "true")
|
||||
:class (str (or nav-btn-class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3") " " (or select-colours ""))
|
||||
label)))
|
||||
|
||||
(defcomp ~blog-sub-settings-label (&key icon label)
|
||||
(defcomp ~header/sub-settings-label (&key icon 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)))
|
||||
|
||||
106
blog/sx/index.sx
106
blog/sx/index.sx
@@ -1,9 +1,9 @@
|
||||
;; 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."))
|
||||
|
||||
(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"
|
||||
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
|
||||
: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"
|
||||
: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
|
||||
(div :class "max-w-full px-3 py-3 space-y-3" cards)
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards)
|
||||
(defcomp ~index/main-panel-posts (&key tabs toggle grid-cls cards)
|
||||
(<> tabs
|
||||
toggle
|
||||
(div :class grid-cls cards)
|
||||
(div :class "pb-8")))
|
||||
|
||||
(defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter)
|
||||
(defcomp ~index/aside (&key search action-buttons tag-groups-filter authors-filter)
|
||||
(<> search
|
||||
action-buttons
|
||||
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
|
||||
@@ -36,12 +36,12 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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
|
||||
nil)
|
||||
|
||||
;; 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)
|
||||
(let* ((posts-href (str blog-url-base "/index"))
|
||||
(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")))
|
||||
(if (= content-type "pages")
|
||||
;; Pages listing
|
||||
(~blog-main-panel-pages
|
||||
:tabs (~blog-content-type-tabs
|
||||
(~index/main-panel-pages
|
||||
:tabs (~index/content-type-tabs
|
||||
:posts-href posts-href :pages-href pages-href
|
||||
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||
:cards (<>
|
||||
(map (lambda (card)
|
||||
(~blog-page-card
|
||||
(~cards/page-card
|
||||
:href (get card "href") :hx-select hx-select
|
||||
:title (get card "title")
|
||||
:has-calendar (get card "has_calendar")
|
||||
@@ -67,14 +67,14 @@
|
||||
:excerpt (get card "excerpt")))
|
||||
(or cards (list)))
|
||||
(if (< page total-pages)
|
||||
(~sentinel-simple
|
||||
(~shared:misc/sentinel-simple
|
||||
:id (str "sentinel-" page "-d")
|
||||
:next-url (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?")
|
||||
"page=" (+ page 1)))
|
||||
(if (not (empty? (or cards (list))))
|
||||
(~end-of-results)
|
||||
(~blog-no-pages)))))
|
||||
(~shared:misc/end-of-results)
|
||||
(~index/no-pages)))))
|
||||
;; Posts listing
|
||||
(let* ((grid-cls (if (= view "tile")
|
||||
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||
@@ -88,19 +88,19 @@
|
||||
(tile-cls (if (= view "tile")
|
||||
"bg-stone-200 text-stone-800"
|
||||
"text-stone-400 hover:text-stone-600")))
|
||||
(~blog-main-panel-posts
|
||||
:tabs (~blog-content-type-tabs
|
||||
(~index/main-panel-posts
|
||||
:tabs (~index/content-type-tabs
|
||||
:posts-href posts-href :pages-href pages-href
|
||||
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
|
||||
:toggle (~view-toggle
|
||||
:toggle (~shared:misc/view-toggle
|
||||
:list-href list-href :tile-href tile-href :hx-select hx-select
|
||||
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
|
||||
:list-svg (~list-svg) :tile-svg (~tile-svg))
|
||||
:list-svg (~shared:misc/list-svg) :tile-svg (~shared:misc/tile-svg))
|
||||
:grid-cls grid-cls
|
||||
:cards (<>
|
||||
(map (lambda (card)
|
||||
(if (= view "tile")
|
||||
(~blog-card-tile
|
||||
(~cards/tile
|
||||
:href (get card "href") :hx-select hx-select
|
||||
:feature-image (get card "feature_image")
|
||||
:title (get card "title") :is-draft (get card "is_draft")
|
||||
@@ -108,7 +108,7 @@
|
||||
:status-timestamp (get card "status_timestamp")
|
||||
:excerpt (get card "excerpt")
|
||||
:tags (get card "tags") :authors (get card "authors"))
|
||||
(~blog-card
|
||||
(~cards/index
|
||||
:slug (get card "slug") :href (get card "href") :hx-select hx-select
|
||||
:title (get card "title") :feature-image (get card "feature_image")
|
||||
:excerpt (get card "excerpt") :is-draft (get card "is_draft")
|
||||
@@ -119,52 +119,52 @@
|
||||
:tags (get card "tags") :authors (get card "authors")
|
||||
:widget (get card "widget"))))
|
||||
(or cards (list)))
|
||||
(~blog-index-sentinel
|
||||
(~index/sentinel
|
||||
:page page :total-pages total-pages
|
||||
:current-local-href current-local-href)))))))
|
||||
|
||||
;; Sentinel for blog index infinite scroll
|
||||
(defcomp ~blog-index-sentinel (&key page total-pages current-local-href)
|
||||
(defcomp ~index/sentinel (&key page total-pages current-local-href)
|
||||
(when (< page total-pages)
|
||||
(let* ((next-url (str current-local-href "?page=" (+ page 1))))
|
||||
(~sentinel-desktop
|
||||
(~shared:misc/sentinel-desktop
|
||||
:id (str "sentinel-" page "-d")
|
||||
:next-url next-url
|
||||
:hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"))))
|
||||
|
||||
;; Blog index action buttons — replaces _action_buttons_sx
|
||||
(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts
|
||||
(defcomp ~index/actions (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href)
|
||||
(~blog-action-buttons-wrapper
|
||||
(~filters/action-buttons-wrapper
|
||||
:inner (<>
|
||||
(when is-admin
|
||||
(<>
|
||||
(~blog-action-button
|
||||
(~filters/action-button
|
||||
:href new-post-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
|
||||
(~blog-action-button
|
||||
(~filters/action-button
|
||||
:href new-page-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
|
||||
(when (and has-user (or draft-count drafts))
|
||||
(if drafts
|
||||
(~blog-drafts-button
|
||||
(~filters/drafts-button
|
||||
:href current-local-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
||||
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
|
||||
(let* ((on-href (str current-local-href
|
||||
(if (contains? current-local-href "?") "&" "?") "drafts=1")))
|
||||
(~blog-drafts-button-amber
|
||||
(~filters/drafts-button-amber
|
||||
:href on-href :hx-select hx-select
|
||||
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
|
||||
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
|
||||
|
||||
;; Tag groups filter — replaces _tag_groups_filter_sx
|
||||
(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select)
|
||||
(~blog-filter-nav
|
||||
(defcomp ~index/tag-groups-filter (&key tag-groups is-any-group hx-select)
|
||||
(~filters/nav
|
||||
:items (<>
|
||||
(~blog-filter-any-topic
|
||||
(~filters/any-topic
|
||||
:cls (if is-any-group
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||
@@ -178,23 +178,23 @@
|
||||
(colour (get grp "colour"))
|
||||
(name (get grp "name"))
|
||||
(icon (if fi
|
||||
(~blog-filter-group-icon-image :src fi :name name)
|
||||
(~blog-filter-group-icon-color
|
||||
(~filters/group-icon-image :src fi :name name)
|
||||
(~filters/group-icon-color
|
||||
:style (if colour
|
||||
(str "background-color: " colour "; color: white;")
|
||||
"background-color: #e7e5e4; color: #57534e;")
|
||||
:initial (slice (or name "?") 0 1)))))
|
||||
(~blog-filter-group-li
|
||||
(~filters/group-li
|
||||
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
|
||||
:hx-select hx-select :icon icon
|
||||
:name name :count (str (get grp "post_count")))))
|
||||
(or tag-groups (list))))))
|
||||
|
||||
;; Authors filter — replaces _authors_filter_sx
|
||||
(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select)
|
||||
(~blog-filter-nav
|
||||
(defcomp ~index/authors-filter (&key authors is-any-author hx-select)
|
||||
(~filters/nav
|
||||
:items (<>
|
||||
(~blog-filter-any-author
|
||||
(~filters/any-author
|
||||
:cls (if is-any-author
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
|
||||
@@ -205,49 +205,49 @@
|
||||
"bg-stone-900 text-white border-stone-900"
|
||||
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
|
||||
(img (get a "profile_image")))
|
||||
(~blog-filter-author-li
|
||||
(~filters/author-li
|
||||
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
|
||||
:hx-select hx-select
|
||||
:icon (when img (~blog-filter-author-icon :src img :name (get a "name")))
|
||||
:icon (when img (~filters/author-icon :src img :name (get a "name")))
|
||||
:name (get a "name")
|
||||
:count (str (get a "published_post_count")))))
|
||||
(or authors (list))))))
|
||||
|
||||
;; Blog index aside — replaces _blog_aside_sx
|
||||
(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts
|
||||
(defcomp ~index/aside-content (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href
|
||||
tag-groups authors is-any-group is-any-author)
|
||||
(~blog-aside
|
||||
:search (~search-desktop)
|
||||
:action-buttons (~blog-index-actions
|
||||
(~index/aside
|
||||
:search (~shared:controls/search-desktop)
|
||||
:action-buttons (~index/actions
|
||||
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||
:draft-count draft-count :drafts drafts
|
||||
:new-post-href new-post-href :new-page-href new-page-href
|
||||
:current-local-href current-local-href)
|
||||
:tag-groups-filter (~blog-index-tag-groups-filter
|
||||
:tag-groups-filter (~index/tag-groups-filter
|
||||
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
|
||||
:authors-filter (~blog-index-authors-filter
|
||||
:authors-filter (~index/authors-filter
|
||||
:authors authors :is-any-author is-any-author :hx-select hx-select)))
|
||||
|
||||
;; Blog index mobile filter — replaces _blog_filter_sx
|
||||
(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts
|
||||
(defcomp ~index/filter-content (&key is-admin has-user hx-select draft-count drafts
|
||||
new-post-href new-page-href current-local-href
|
||||
tag-groups authors is-any-group is-any-author
|
||||
tg-summary au-summary)
|
||||
(~mobile-filter
|
||||
(~shared:controls/mobile-filter
|
||||
:filter-summary (<>
|
||||
(~search-mobile)
|
||||
(~shared:controls/search-mobile)
|
||||
(when (not (= tg-summary ""))
|
||||
(~blog-filter-summary :text tg-summary))
|
||||
(~filters/summary :text tg-summary))
|
||||
(when (not (= au-summary ""))
|
||||
(~blog-filter-summary :text au-summary)))
|
||||
:action-buttons (~blog-index-actions
|
||||
(~filters/summary :text au-summary)))
|
||||
:action-buttons (~index/actions
|
||||
:is-admin is-admin :has-user has-user :hx-select hx-select
|
||||
:draft-count draft-count :drafts drafts
|
||||
:new-post-href new-post-href :new-page-href new-page-href
|
||||
:current-local-href current-local-href)
|
||||
:filter-details (<>
|
||||
(~blog-index-tag-groups-filter
|
||||
(~index/tag-groups-filter
|
||||
: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))))
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"
|
||||
(if (= width "wide") " kg-width-wide"
|
||||
(if (= width "full") " kg-width-full" "")))
|
||||
@@ -19,7 +19,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"
|
||||
(div :class "kg-gallery-container"
|
||||
(map (lambda (row)
|
||||
@@ -36,19 +36,19 @@
|
||||
;; HTML card — wraps user-pasted HTML so the editor can identify the block.
|
||||
;; 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))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"
|
||||
(~rich-text :html html)
|
||||
(when caption (figcaption caption))))
|
||||
@@ -56,7 +56,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"
|
||||
(a :class "kg-bookmark-container" :href url
|
||||
(div :class "kg-bookmark-content"
|
||||
@@ -75,7 +75,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"))
|
||||
(when emoji (div :class "kg-callout-emoji" emoji))
|
||||
(div :class "kg-callout-text" (or content ""))))
|
||||
@@ -83,14 +83,14 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"))
|
||||
(a :href url :class "kg-btn kg-btn-accent" (or text ""))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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-toggle-heading"
|
||||
(h4 :class "kg-toggle-heading-text" (or heading ""))
|
||||
@@ -101,7 +101,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"
|
||||
(if thumbnail
|
||||
(img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
|
||||
@@ -124,7 +124,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"
|
||||
(if (= width "wide") " kg-width-wide"
|
||||
(if (= width "full") " kg-width-full" "")))
|
||||
@@ -136,7 +136,7 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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"
|
||||
(a :class "kg-file-card-container" :href src :download (or filename "")
|
||||
(div :class "kg-file-card-contents"
|
||||
@@ -149,5 +149,5 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Paywall marker
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~kg-paywall ()
|
||||
(defcomp ~kg_cards/kg-paywall ()
|
||||
(~rich-text :html "<!--members-only-->"))
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
;; --- Blog header (invisible row for blog-header-child swap target) ---
|
||||
|
||||
(defcomp ~blog-header (&key oob)
|
||||
(~menu-row-sx :id "blog-row" :level 1
|
||||
(defcomp ~layouts/header (&key oob)
|
||||
(~shared:layout/menu-row-sx :id "blog-row" :level 1
|
||||
:link-label-content (div)
|
||||
:child-id "blog-header-child" :oob oob))
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
(defmacro ~blog-settings-header-auto (oob)
|
||||
(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-label-content (~blog-admin-label)
|
||||
:nav (~blog-settings-nav)
|
||||
:link-label-content (~header/admin-label)
|
||||
:nav (~layouts/settings-nav)
|
||||
:child-id "root-settings-header-child"
|
||||
:oob (unquote oob))))
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
|
||||
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
|
||||
(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-label-content (~blog-sub-settings-label
|
||||
:link-label-content (~header/sub-settings-label
|
||||
:icon (str "fa fa-" (unquote icon))
|
||||
:label (unquote label))
|
||||
:child-id (unquote child-id)
|
||||
@@ -35,47 +35,47 @@
|
||||
;; Blog layout (root + blog header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-layout-full ()
|
||||
(defcomp ~layouts/full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-header)))
|
||||
(~layouts/header)))
|
||||
|
||||
(defcomp ~blog-layout-oob ()
|
||||
(<> (~blog-header :oob true)
|
||||
(~clear-oob-div :id "blog-header-child")
|
||||
(defcomp ~layouts/oob ()
|
||||
(<> (~layouts/header :oob true)
|
||||
(~shared:layout/clear-oob-div :id "blog-header-child")
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Settings layout (root + settings header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-settings-layout-full ()
|
||||
(defcomp ~layouts/settings-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)))
|
||||
|
||||
(defcomp ~blog-settings-layout-oob ()
|
||||
(defcomp ~layouts/settings-layout-oob ()
|
||||
(<> (~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)))
|
||||
|
||||
(defcomp ~blog-settings-layout-mobile ()
|
||||
(~blog-settings-nav))
|
||||
(defcomp ~layouts/settings-layout-mobile ()
|
||||
(~layouts/settings-nav))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Cache layout (root + settings + cache sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-cache-layout-full ()
|
||||
(defcomp ~layouts/cache-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"cache-row" "cache-header-child"
|
||||
"settings.defpage_cache_page" "refresh" "Cache")))
|
||||
|
||||
(defcomp ~blog-cache-layout-oob ()
|
||||
(defcomp ~layouts/cache-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"cache-row" "cache-header-child"
|
||||
"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)
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -83,18 +83,18 @@
|
||||
;; Snippets layout (root + settings + snippets sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-snippets-layout-full ()
|
||||
(defcomp ~layouts/snippets-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"snippets-row" "snippets-header-child"
|
||||
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
|
||||
|
||||
(defcomp ~blog-snippets-layout-oob ()
|
||||
(defcomp ~layouts/snippets-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"snippets-row" "snippets-header-child"
|
||||
"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)
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -102,18 +102,18 @@
|
||||
;; Menu Items layout (root + settings + menu-items sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-menu-items-layout-full ()
|
||||
(defcomp ~layouts/menu-items-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"menu_items-row" "menu_items-header-child"
|
||||
"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
|
||||
"menu_items-row" "menu_items-header-child"
|
||||
"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)
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -121,18 +121,18 @@
|
||||
;; Tag Groups layout (root + settings + tag-groups sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-tag-groups-layout-full ()
|
||||
(defcomp ~layouts/tag-groups-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"tag-groups-row" "tag-groups-header-child"
|
||||
"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
|
||||
"tag-groups-row" "tag-groups-header-child"
|
||||
"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)
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -140,31 +140,31 @@
|
||||
;; 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)
|
||||
(~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"
|
||||
: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")
|
||||
:child-id "tag-groups-header-child")))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-layout-oob ()
|
||||
(<> (~menu-row-sx :id "tag-groups-row" :level 2
|
||||
(defcomp ~layouts/tag-group-edit-layout-oob ()
|
||||
(<> (~shared:layout/menu-row-sx :id "tag-groups-row" :level 2
|
||||
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
|
||||
: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")
|
||||
:child-id "tag-groups-header-child"
|
||||
: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)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Settings nav links — uses IO primitives ---
|
||||
|
||||
(defcomp ~blog-settings-nav ()
|
||||
(defcomp ~layouts/settings-nav ()
|
||||
(let* ((sc (select-colours))
|
||||
(links (list
|
||||
(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 "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache"))))
|
||||
(<> (map (lambda (lnk)
|
||||
(~nav-link
|
||||
(~shared:layout/nav-link
|
||||
:href (url-for (get lnk "endpoint"))
|
||||
:icon (get lnk "icon")
|
||||
:label (get lnk "label")
|
||||
@@ -181,5 +181,5 @@
|
||||
|
||||
;; --- Editor panel wrapper ---
|
||||
|
||||
(defcomp ~blog-editor-panel (&key parts)
|
||||
(defcomp ~layouts/editor-panel (&key parts)
|
||||
(<> parts))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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"
|
||||
:data-page-id id :data-page-title title :data-page-slug slug
|
||||
:data-page-image (or feature-image "")
|
||||
@@ -11,50 +11,50 @@
|
||||
(div :class "font-medium truncate" title)
|
||||
(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"
|
||||
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"
|
||||
:sx-vals (str "{\"q\": \"" query "\", \"page\": " next-page "}")
|
||||
:class "p-3 text-center text-sm text-stone-400"
|
||||
(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"
|
||||
(str "No pages found matching \"" query "\"")))
|
||||
|
||||
;; 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)
|
||||
(~page-search-empty :query query)
|
||||
(~menu_items/page-search-empty :query query)
|
||||
(when pages
|
||||
(~page-search-results
|
||||
(~menu_items/page-search-results
|
||||
:items (<> (map (lambda (p)
|
||||
(~page-search-item
|
||||
(~menu_items/page-search-item
|
||||
:id (get p "id") :title (get p "title")
|
||||
:slug (get p "slug") :feature-image (get p "feature_image")))
|
||||
pages))
|
||||
: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)
|
||||
(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)
|
||||
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
|
||||
(~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||
(~shared:misc/scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
|
||||
:arrow-cls arrow-cls
|
||||
:left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
|
||||
:scroll-hs scroll-hs
|
||||
:right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
|
||||
: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")))
|
||||
(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"))
|
||||
(~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")))))
|
||||
items))
|
||||
:oob true)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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"
|
||||
:sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
|
||||
(label :class "flex items-center gap-3 cursor-pointer"
|
||||
@@ -18,33 +18,33 @@
|
||||
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
||||
" 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"
|
||||
(~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
|
||||
: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"
|
||||
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
|
||||
form sumup))
|
||||
|
||||
;; 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"
|
||||
(div (span :class "font-medium" name)
|
||||
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
|
||||
(button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML"
|
||||
:sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
|
||||
|
||||
(defcomp ~blog-markets-list (&key items)
|
||||
(defcomp ~settings/markets-list (&key 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."))
|
||||
|
||||
(defcomp ~blog-markets-panel (&key list create-url)
|
||||
(defcomp ~settings/markets-panel (&key list create-url)
|
||||
(div :id "markets-panel"
|
||||
(h3 :class "text-lg font-semibold mb-3" "Markets")
|
||||
list
|
||||
@@ -59,17 +59,17 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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
|
||||
sumup-configured checkout-prefix)
|
||||
(~blog-features-panel
|
||||
:form (~blog-features-form
|
||||
(~settings/features-panel
|
||||
:form (~settings/features-form
|
||||
:features-url features-url
|
||||
:calendar-checked calendar-checked
|
||||
:market-checked market-checked
|
||||
:hs-trigger "on change trigger submit on closest <form/>")
|
||||
:sumup (when show-sumup
|
||||
(~blog-sumup-form
|
||||
(~settings/sumup-form
|
||||
:sumup-url sumup-url
|
||||
:merchant-code merchant-code
|
||||
:placeholder placeholder
|
||||
@@ -77,13 +77,13 @@
|
||||
:checkout-prefix checkout-prefix))))
|
||||
|
||||
;; Markets panel composition — replaces render_markets_panel
|
||||
(defcomp ~blog-markets-panel-content (&key markets create-url)
|
||||
(~blog-markets-panel
|
||||
(defcomp ~settings/markets-panel-content (&key markets create-url)
|
||||
(~settings/markets-panel
|
||||
:list (if (empty? (or markets (list)))
|
||||
(~blog-markets-empty)
|
||||
(~blog-markets-list
|
||||
(~settings/markets-empty)
|
||||
(~settings/markets-list
|
||||
:items (map (lambda (m)
|
||||
(~blog-market-item
|
||||
(~settings/market-item
|
||||
:name (get m "name")
|
||||
:slug (get m "slug")
|
||||
:delete-url (get m "delete_url")
|
||||
@@ -93,11 +93,11 @@
|
||||
|
||||
;; 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")
|
||||
(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"
|
||||
: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?"
|
||||
@@ -115,14 +115,14 @@
|
||||
(div :class "text-xs text-stone-600 mt-1" date-str))
|
||||
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
|
||||
|
||||
(defcomp ~blog-associated-entries-content (&key items)
|
||||
(defcomp ~settings/associated-entries-content (&key items)
|
||||
(div :class "space-y-1" items))
|
||||
|
||||
(defcomp ~blog-associated-entries-empty ()
|
||||
(defcomp ~settings/associated-entries-empty ()
|
||||
(div :class "text-sm text-stone-400"
|
||||
"No entries associated yet. Browse calendars below to add entries."))
|
||||
|
||||
(defcomp ~blog-associated-entries-panel (&key content)
|
||||
(defcomp ~settings/associated-entries-panel (&key content)
|
||||
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
||||
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
||||
content))
|
||||
@@ -131,17 +131,17 @@
|
||||
;; Associated entries composition — replaces _render_associated_entries
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-associated-entries-from-data (&key entries csrf)
|
||||
(~blog-associated-entries-panel
|
||||
(defcomp ~settings/associated-entries-from-data (&key entries csrf)
|
||||
(~settings/associated-entries-panel
|
||||
:content (if (empty? (or entries (list)))
|
||||
(~blog-associated-entries-empty)
|
||||
(~blog-associated-entries-content
|
||||
(~settings/associated-entries-empty)
|
||||
(~settings/associated-entries-content
|
||||
:items (map (lambda (e)
|
||||
(~blog-associated-entry
|
||||
(~settings/associated-entry
|
||||
:confirm-text (get e "confirm_text")
|
||||
:toggle-url (get e "toggle_url")
|
||||
: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")
|
||||
:date-str (get e "date_str")))
|
||||
(or entries (list)))))))
|
||||
@@ -150,7 +150,7 @@
|
||||
;; 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"
|
||||
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
|
||||
(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 "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"
|
||||
entries-panel
|
||||
(div :class "space-y-3"
|
||||
@@ -171,7 +171,7 @@
|
||||
(if (empty? (or calendars (list)))
|
||||
(div :class "text-sm text-stone-400" "No calendars found.")
|
||||
(map (lambda (cal)
|
||||
(~blog-calendar-browser-item
|
||||
(~settings/calendar-browser-item
|
||||
:name (get cal "name")
|
||||
:title (get cal "title")
|
||||
:image (get cal "image")
|
||||
@@ -182,17 +182,17 @@
|
||||
;; 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
|
||||
: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
|
||||
(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)
|
||||
(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
|
||||
tags feature-image-alt
|
||||
meta-title meta-description canonical-url
|
||||
@@ -209,19 +209,19 @@
|
||||
(input :type "hidden" :name "updated_at" :value (or updated-at ""))
|
||||
(div :class "space-y-[12px] mt-[16px]"
|
||||
;; 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 "")
|
||||
: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"
|
||||
:value (or published-at "") :class input-cls))
|
||||
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
|
||||
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
|
||||
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
|
||||
(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
|
||||
(option :value "public" :selected (= visibility "public") "Public")
|
||||
(option :value "members" :selected (= visibility "members") "Members")
|
||||
@@ -231,57 +231,57 @@
|
||||
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
|
||||
(span :class "text-[14px] text-stone-600" "Email only")))))
|
||||
;; Tags
|
||||
(~blog-settings-section :title "Tags" :content
|
||||
(div (~blog-settings-field-label :text "Tags (comma-separated)" :field-for "settings-tags")
|
||||
(~settings/section :title "Tags" :content
|
||||
(div (~settings/field-label :text "Tags (comma-separated)" :field-for "settings-tags")
|
||||
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
|
||||
:placeholder "news, updates, featured" :class input-cls)
|
||||
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
|
||||
;; Feature Image
|
||||
(~blog-settings-section :title "Feature Image" :content
|
||||
(div (~blog-settings-field-label :text "Alt text" :field-for "settings-feature_image_alt")
|
||||
(~settings/section :title "Feature Image" :content
|
||||
(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"
|
||||
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
|
||||
;; 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 "")
|
||||
:placeholder "SEO title" :maxlength "300" :class input-cls)
|
||||
(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"
|
||||
:placeholder "SEO description" :maxlength "500" :class textarea-cls
|
||||
(or meta-description ""))
|
||||
(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"
|
||||
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
|
||||
;; 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))
|
||||
(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
|
||||
(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 "")
|
||||
:placeholder "https://..." :class input-cls))))
|
||||
;; 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"
|
||||
: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
|
||||
(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"
|
||||
:value (or twitter-image "") :placeholder "https://..." :class input-cls))))
|
||||
;; Advanced
|
||||
(~blog-settings-section :title "Advanced" :content
|
||||
(div (~blog-settings-field-label :text "Custom template" :field-for "settings-custom_template")
|
||||
(~settings/section :title "Advanced" :content
|
||||
(div (~settings/field-label :text "Custom template" :field-for "settings-custom_template")
|
||||
(input :type "text" :name "custom_template" :id "settings-custom_template"
|
||||
: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"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:auth :admin
|
||||
:layout :blog
|
||||
:data (editor-data)
|
||||
:content (~blog-editor-content
|
||||
:content (~editor/content
|
||||
:csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label :css-href css-href
|
||||
:js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
@@ -20,7 +20,7 @@
|
||||
:auth :admin
|
||||
:layout :blog
|
||||
:data (editor-page-data)
|
||||
:content (~blog-editor-content
|
||||
:content (~editor/content
|
||||
:csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label :css-href css-href
|
||||
:js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
@@ -33,21 +33,21 @@
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "admin")
|
||||
:data (post-admin-data slug)
|
||||
:content (~blog-admin-placeholder))
|
||||
:content (~admin/placeholder))
|
||||
|
||||
(defpage post-data
|
||||
:path "/<slug>/admin/data/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "data")
|
||||
: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
|
||||
:path "/<slug>/admin/preview/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "preview")
|
||||
:data (post-preview-data slug)
|
||||
:content (~blog-preview-content
|
||||
:content (~admin/preview-content
|
||||
:sx-pretty sx-pretty :json-pretty json-pretty
|
||||
:sx-rendered sx-rendered :lex-rendered lex-rendered))
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "entries")
|
||||
:data (post-entries-data slug)
|
||||
:content (~blog-entries-browser-content
|
||||
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf)
|
||||
:content (~settings/entries-browser-content
|
||||
:entries-panel (~settings/associated-entries-from-data :entries entries :csrf csrf)
|
||||
:calendars calendars))
|
||||
|
||||
(defpage post-settings
|
||||
@@ -65,7 +65,7 @@
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "settings")
|
||||
:data (post-settings-data slug)
|
||||
:content (~blog-settings-form-content
|
||||
:content (~settings/form-content
|
||||
:csrf csrf :updated-at updated-at :is-page is-page
|
||||
:save-success save-success :slug settings-slug
|
||||
:published-at published-at :featured featured
|
||||
@@ -82,7 +82,7 @@
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "edit")
|
||||
:data (post-edit-data slug)
|
||||
:content (~blog-edit-content
|
||||
:content (~editor/edit-content
|
||||
:csrf csrf :updated-at updated-at
|
||||
:title-val title-val :excerpt-val excerpt-val
|
||||
:feature-image feature-image :feature-image-caption feature-image-caption
|
||||
@@ -111,7 +111,7 @@
|
||||
:auth :admin
|
||||
:layout :blog-cache
|
||||
: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 ---
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
:auth :login
|
||||
:layout :blog-snippets
|
||||
:data (service "blog-page" "snippets-data")
|
||||
:content (~blog-snippets-content
|
||||
:content (~admin/snippets-content
|
||||
:snippets snippets :is-admin is-admin :csrf csrf))
|
||||
|
||||
; --- Menu Items ---
|
||||
@@ -130,7 +130,7 @@
|
||||
:auth :admin
|
||||
:layout :blog-menu-items
|
||||
: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))
|
||||
|
||||
; --- Tag Groups ---
|
||||
@@ -140,7 +140,7 @@
|
||||
:auth :admin
|
||||
:layout :blog-tag-groups
|
||||
:data (service "blog-page" "tag-groups-data")
|
||||
:content (~blog-tag-groups-content
|
||||
:content (~admin/tag-groups-content
|
||||
:groups groups :unassigned-tags unassigned-tags
|
||||
:create-url create-url :csrf csrf))
|
||||
|
||||
@@ -149,6 +149,6 @@
|
||||
:auth :admin
|
||||
:layout :blog-tag-group-edit
|
||||
: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
|
||||
:save-url save-url :delete-url delete-url :csrf csrf))
|
||||
|
||||
@@ -167,7 +167,7 @@ class TestCards:
|
||||
result = lexical_to_sx(_doc({
|
||||
"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):
|
||||
result = lexical_to_sx(_doc({
|
||||
@@ -189,7 +189,7 @@ class TestCards:
|
||||
"type": "bookmark", "url": "https://example.com",
|
||||
"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 ':title "Example"' in result
|
||||
|
||||
@@ -199,7 +199,7 @@ class TestCards:
|
||||
"calloutEmoji": "💡",
|
||||
"children": [_text("Note")]
|
||||
}))
|
||||
assert "(~kg-callout " in result
|
||||
assert "(~kg_cards/kg-callout " in result
|
||||
assert ':color "blue"' in result
|
||||
|
||||
def test_button(self):
|
||||
@@ -207,7 +207,7 @@ class TestCards:
|
||||
"type": "button", "buttonText": "Click",
|
||||
"buttonUrl": "https://example.com"
|
||||
}))
|
||||
assert "(~kg-button " in result
|
||||
assert "(~kg_cards/kg-button " in result
|
||||
assert ':text "Click"' in result
|
||||
|
||||
def test_toggle(self):
|
||||
@@ -215,28 +215,28 @@ class TestCards:
|
||||
"type": "toggle", "heading": "FAQ",
|
||||
"children": [_text("Answer")]
|
||||
}))
|
||||
assert "(~kg-toggle " in result
|
||||
assert "(~kg_cards/kg-toggle " in result
|
||||
assert ':heading "FAQ"' in result
|
||||
|
||||
def test_html(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "html", "html": "<div>custom</div>"
|
||||
}))
|
||||
assert result == '(~kg-html (div "custom"))'
|
||||
assert result == '(~kg_cards/kg-html (div "custom"))'
|
||||
|
||||
def test_embed(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "embed", "html": "<iframe></iframe>",
|
||||
"caption": "Video"
|
||||
}))
|
||||
assert "(~kg-embed " in result
|
||||
assert "(~kg_cards/kg-embed " in result
|
||||
assert ':caption "Video"' in result
|
||||
|
||||
def test_markdown(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "markdown", "markdown": "**bold** text"
|
||||
}))
|
||||
assert result.startswith("(~kg-md ")
|
||||
assert result.startswith("(~kg_cards/kg-md ")
|
||||
assert "(p " in result
|
||||
assert "(strong " in result
|
||||
|
||||
@@ -244,14 +244,14 @@ class TestCards:
|
||||
result = lexical_to_sx(_doc({
|
||||
"type": "video", "src": "v.mp4", "cardWidth": "wide"
|
||||
}))
|
||||
assert "(~kg-video " in result
|
||||
assert "(~kg_cards/kg-video " in result
|
||||
assert ':width "wide"' in result
|
||||
|
||||
def test_audio(self):
|
||||
result = lexical_to_sx(_doc({
|
||||
"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
|
||||
|
||||
def test_file(self):
|
||||
@@ -259,13 +259,13 @@ class TestCards:
|
||||
"type": "file", "src": "f.pdf", "fileName": "doc.pdf",
|
||||
"fileSize": 2100000
|
||||
}))
|
||||
assert "(~kg-file " in result
|
||||
assert "(~kg_cards/kg-file " in result
|
||||
assert ':filename "doc.pdf"' in result
|
||||
assert "MB" in result
|
||||
|
||||
def test_paywall(self):
|
||||
result = lexical_to_sx(_doc({"type": "paywall"}))
|
||||
assert result == "(~kg-paywall)"
|
||||
assert result == "(~kg_cards/kg-paywall)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
;; 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"
|
||||
(div (div :class "font-medium" name)
|
||||
(div :class "text-xs text-stone-500" date-str))
|
||||
(div :class "ml-4 font-medium" cost)))
|
||||
|
||||
(defcomp ~cart-cal-section (&key items)
|
||||
(defcomp ~calendar/cal-section (&key items)
|
||||
(div :class "mt-6 border-t border-stone-200 pt-4"
|
||||
(h2 :class "text-base font-semibold mb-2" "Calendar bookings")
|
||||
(ul :class "space-y-2" items)))
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
;; Renders the "orders" link for the account dashboard nav.
|
||||
|
||||
(defhandler account-nav-item (&key)
|
||||
(~account-nav-item
|
||||
(~shared:fragments/account-nav-item
|
||||
:href (app-url "cart" "/orders/")
|
||||
:label "orders"))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
(count (+ (or (get summary "count") 0)
|
||||
(or (get summary "calendar_count") 0)
|
||||
(or (get summary "ticket_count") 0))))
|
||||
(~cart-mini
|
||||
(~shared:fragments/cart-mini
|
||||
:cart-count count
|
||||
:blog-url (app-url "blog" "")
|
||||
:cart-url (app-url "cart" "")
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
;; 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"))
|
||||
|
||||
(defcomp ~cart-page-label (&key feature-image title)
|
||||
(defcomp ~header/page-label (&key feature-image title)
|
||||
(<> (when feature-image
|
||||
(~cart-page-label-img :src feature-image))
|
||||
(~header/page-label-img :src feature-image))
|
||||
(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"
|
||||
(i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts"))
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
;; 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"))
|
||||
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~cart-item-no-price ()
|
||||
(defcomp ~items/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"
|
||||
(i :class "fa-solid fa-triangle-exclamation text-[0.6rem]" :aria-hidden "true")
|
||||
" This item is no longer available or price has changed"))
|
||||
|
||||
(defcomp ~cart-item-brand (&key (brand :as string))
|
||||
(defcomp ~items/brand (&key (brand :as string))
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (when img img))
|
||||
(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" "+")))
|
||||
(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 :id "cart"
|
||||
(div (section :class "space-y-3 sm:space-y-4" items cal tickets)
|
||||
summary))))
|
||||
|
||||
;; Assembled cart item from serialized data — replaces Python _cart_item_sx
|
||||
(defcomp ~cart-item-from-data (&key (item :as dict))
|
||||
(defcomp ~items/from-data (&key (item :as dict))
|
||||
(let* ((slug (or (get item "slug") ""))
|
||||
(title (or (get item "title") ""))
|
||||
(image (get item "image"))
|
||||
@@ -71,48 +71,48 @@
|
||||
(qty-url (or (get item "qty_url") ""))
|
||||
(csrf (csrf-token))
|
||||
(line-total (when unit-price (* unit-price quantity))))
|
||||
(~cart-item
|
||||
(~items/index
|
||||
:id (str "cart-item-" slug)
|
||||
:img (if image
|
||||
(~cart-item-img :src image :alt title)
|
||||
(~img-or-placeholder :src nil
|
||||
(~items/img :src image :alt title)
|
||||
(~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"
|
||||
:placeholder-text "No image"))
|
||||
:prod-url prod-url
|
||||
:title title
|
||||
:brand (when brand (~cart-item-brand :brand brand))
|
||||
:deleted (when is-deleted (~cart-item-deleted))
|
||||
:brand (when brand (~items/brand :brand brand))
|
||||
:deleted (when is-deleted (~items/deleted))
|
||||
: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))
|
||||
(~cart-item-price-was :text (str symbol (format-decimal regular-price 2)))))
|
||||
(~cart-item-no-price))
|
||||
(~items/price-was :text (str symbol (format-decimal regular-price 2)))))
|
||||
(~items/no-price))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:minus (str (- quantity 1))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
:line-total (when line-total
|
||||
(~cart-item-line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
|
||||
(~items/line-total :text (str "Line total: " symbol (format-decimal line-total 2)))))))
|
||||
|
||||
;; Assembled calendar entries section — replaces Python _calendar_entries_sx
|
||||
(defcomp ~cart-cal-section-from-data (&key (entries :as list))
|
||||
(defcomp ~items/cal-section-from-data (&key (entries :as list))
|
||||
(when (not (empty? entries))
|
||||
(~cart-cal-section
|
||||
(~calendar/cal-section
|
||||
:items (map (lambda (e)
|
||||
(let* ((name (or (get e "name") ""))
|
||||
(date-str (or (get e "date_str") "")))
|
||||
(~cart-cal-entry
|
||||
(~calendar/cal-entry
|
||||
:name name :date-str date-str
|
||||
:cost (str "\u00a3" (format-decimal (or (get e "cost") 0) 2)))))
|
||||
entries))))
|
||||
|
||||
;; Assembled ticket groups section — replaces Python _ticket_groups_sx
|
||||
(defcomp ~cart-tickets-section-from-data (&key (ticket-groups :as list))
|
||||
(defcomp ~items/tickets-section-from-data (&key (ticket-groups :as list))
|
||||
(when (not (empty? ticket-groups))
|
||||
(let* ((csrf (csrf-token))
|
||||
(qty-url (url-for "cart_global.update_ticket_quantity")))
|
||||
(~cart-tickets-section
|
||||
(~tickets/section
|
||||
:items (map (lambda (tg)
|
||||
(let* ((name (or (get tg "entry_name") ""))
|
||||
(tt-name (get tg "ticket_type_name"))
|
||||
@@ -122,14 +122,14 @@
|
||||
(entry-id (str (or (get tg "entry_id") "")))
|
||||
(tt-id (get tg "ticket_type_id"))
|
||||
(date-str (or (get tg "date_str") "")))
|
||||
(~cart-ticket-article
|
||||
(~tickets/article
|
||||
: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
|
||||
:price (str "\u00a3" (format-decimal price 2))
|
||||
:qty-url qty-url :csrf csrf
|
||||
:entry-id entry-id
|
||||
:type-hidden (when tt-id (~cart-ticket-type-hidden :value (str tt-id)))
|
||||
:type-hidden (when tt-id (~tickets/type-hidden :value (str tt-id)))
|
||||
:minus (str (max (- quantity 1) 0))
|
||||
:qty (str quantity)
|
||||
:plus (str (+ quantity 1))
|
||||
@@ -137,29 +137,29 @@
|
||||
ticket-groups)))))
|
||||
|
||||
;; 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?))
|
||||
(~cart-summary-panel
|
||||
(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?))
|
||||
(~summary/panel
|
||||
:item-count (str item-count)
|
||||
:subtotal (str symbol (format-decimal grand-total 2))
|
||||
:checkout (if is-logged-in
|
||||
(~cart-checkout-form
|
||||
(~summary/checkout-form
|
||||
:action checkout-action :csrf (csrf-token)
|
||||
: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
|
||||
(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)))
|
||||
(empty? (or cal-entries (list)))
|
||||
(empty? (or ticket-groups (list))))
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :id "cart"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
(~cart-page-panel
|
||||
:items (map (lambda (item) (~cart-item-from-data :item item)) (or cart-items (list)))
|
||||
(~shared:misc/empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
(~items/page-panel
|
||||
:items (map (lambda (item) (~items/from-data :item item)) (or cart-items (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))))
|
||||
(~cart-tickets-section-from-data :ticket-groups ticket-groups))
|
||||
(~items/tickets-section-from-data :ticket-groups ticket-groups))
|
||||
:summary summary)))
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
(quasiquote
|
||||
(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-label "cart" :icon "fa fa-shopping-cart"
|
||||
:child-id "cart-header-child")
|
||||
(~header-child-sx :id "cart-header-child"
|
||||
:inner (~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
|
||||
(~shared:layout/header-child-sx :id "cart-header-child"
|
||||
:inner (~shared:layout/menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
|
||||
: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")
|
||||
: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)))))))
|
||||
|
||||
(defmacro ~cart-page-header-oob ()
|
||||
@@ -28,14 +28,14 @@
|
||||
(quasiquote
|
||||
(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-label-content (~cart-page-label
|
||||
:link-label-content (~header/page-label
|
||||
:feature-image (get __cpctx "feature-image")
|
||||
: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)
|
||||
(~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-label "cart" :icon "fa fa-shopping-cart"
|
||||
:child-id "cart-header-child"
|
||||
@@ -45,12 +45,12 @@
|
||||
;; cart-page layout: root + cart row + page-cart row
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~cart-page-layout-full ()
|
||||
(defcomp ~layouts/page-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~cart-page-header-auto))))
|
||||
|
||||
(defcomp ~cart-page-layout-oob ()
|
||||
(defcomp ~layouts/page-layout-oob ()
|
||||
(<> (~cart-page-header-oob)
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -59,14 +59,14 @@
|
||||
;; 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~post-header-auto nil))))
|
||||
|
||||
(defcomp ~cart-admin-layout-oob (&key selected)
|
||||
(defcomp ~layouts/admin-layout-oob (&key selected)
|
||||
(<> (~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))
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -74,63 +74,63 @@
|
||||
;; 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~auth-header-row-simple-auto)
|
||||
(~header-child-sx :id "auth-header-child"
|
||||
:inner (~orders-header-row :list-url list-url))))))
|
||||
(~shared:layout/header-child-sx :id "auth-header-child"
|
||||
: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)
|
||||
(~oob-header-sx
|
||||
(~shared:layout/oob-header-sx
|
||||
: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)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~auth-header-row-simple-auto)
|
||||
(~header-child-sx :id "auth-header-child"
|
||||
:inner (<> (~orders-header-row :list-url list-url)
|
||||
(~header-child-sx :id "orders-header-child"
|
||||
:inner (~menu-row-sx :id "order-row" :level 3 :colour "sky"
|
||||
(~shared:layout/header-child-sx :id "auth-header-child"
|
||||
:inner (<> (~shared:auth/orders-header-row :list-url list-url)
|
||||
(~shared:layout/header-child-sx :id "orders-header-child"
|
||||
:inner (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
|
||||
:link-href detail-url
|
||||
:link-label order-label
|
||||
:icon "fa fa-gbp"))))))))
|
||||
|
||||
(defcomp ~cart-order-detail-layout-oob (&key detail-url order-label)
|
||||
(<> (~oob-header-sx
|
||||
(defcomp ~layouts/order-detail-layout-oob (&key detail-url order-label)
|
||||
(<> (~shared:layout/oob-header-sx
|
||||
: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
|
||||
:icon "fa fa-gbp" :oob true))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- 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))
|
||||
|
||||
;; Composition defcomp — replaces Python loop in render_orders_rows
|
||||
(defcomp ~cart-orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
|
||||
(~cart-orders-rows
|
||||
(defcomp ~layouts/orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
|
||||
(~layouts/orders-rows
|
||||
: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)))
|
||||
: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)
|
||||
(~order-end-row))))
|
||||
(~shared:orders/end-row))))
|
||||
|
||||
;; Composition defcomp — replaces conditional composition in render_checkout_error_page
|
||||
(defcomp ~cart-checkout-error-from-data (&key msg order-id back-url)
|
||||
(~checkout-error-content
|
||||
(defcomp ~layouts/checkout-error-from-data (&key msg order-id back-url)
|
||||
(~shared:orders/checkout-error-content
|
||||
: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))
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
;; 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"
|
||||
(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"
|
||||
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"))
|
||||
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(div :class "flex items-start gap-4"
|
||||
img
|
||||
@@ -25,7 +25,7 @@
|
||||
(div :class "text-lg font-bold text-stone-900" total)
|
||||
(div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192")))))
|
||||
|
||||
(defcomp ~cart-orphan-card (&key badges (total :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 "flex items-start gap-4"
|
||||
(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-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 "space-y-4" cards)))
|
||||
|
||||
(defcomp ~cart-empty ()
|
||||
(defcomp ~overview/empty ()
|
||||
(div :class "max-w-full px-3 py-3 space-y-3"
|
||||
(div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"
|
||||
(~empty-state :icon "fa fa-shopping-cart" :message "Your cart is empty" :cls "text-center"))))
|
||||
(~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
|
||||
(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"))
|
||||
(product-count (or (get grp "product_count") 0))
|
||||
(calendar-count (or (get grp "calendar_count") 0))
|
||||
@@ -55,13 +55,13 @@
|
||||
(market-place (get grp "market_place"))
|
||||
(badges (<>
|
||||
(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))))
|
||||
(when (> calendar-count 0)
|
||||
(~cart-badge :icon "fa fa-calendar"
|
||||
(~overview/badge :icon "fa fa-calendar"
|
||||
:text (str calendar-count " booking" (pluralize calendar-count))))
|
||||
(when (> ticket-count 0)
|
||||
(~cart-badge :icon "fa fa-ticket"
|
||||
(~overview/badge :icon "fa fa-ticket"
|
||||
:text (str ticket-count " ticket" (pluralize ticket-count)))))))
|
||||
(if post
|
||||
(let* ((slug (or (get post "slug") ""))
|
||||
@@ -69,26 +69,26 @@
|
||||
(feature-image (get post "feature_image"))
|
||||
(mp-name (if market-place (or (get market-place "name") "") ""))
|
||||
(display-title (if (!= mp-name "") mp-name title)))
|
||||
(~cart-group-card
|
||||
(~overview/group-card
|
||||
:href (str cart-url-base "/" slug "/")
|
||||
:img (if feature-image
|
||||
(~cart-group-card-img :src feature-image :alt title)
|
||||
(~img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
|
||||
(~overview/group-card-img :src feature-image :alt title)
|
||||
(~shared:misc/img-or-placeholder :src nil :size-cls "h-16 w-16 rounded-xl"
|
||||
:placeholder-icon "fa fa-store text-xl"))
|
||||
:display-title display-title
|
||||
:subtitle (when (!= mp-name "")
|
||||
(~cart-mp-subtitle :title title))
|
||||
:badges (~cart-badges-wrap :badges badges)
|
||||
(~overview/mp-subtitle :title title))
|
||||
:badges (~overview/badges-wrap :badges badges)
|
||||
:total (str "\u00a3" (format-decimal total 2))))
|
||||
(~cart-orphan-card
|
||||
:badges (~cart-badges-wrap :badges badges)
|
||||
(~overview/orphan-card
|
||||
:badges (~overview/badges-wrap :badges badges)
|
||||
:total (str "\u00a3" (format-decimal total 2))))))
|
||||
|
||||
;; Assembled cart overview content — replaces Python _overview_main_panel_sx
|
||||
(defcomp ~cart-overview-content (&key (page-groups :as list) (cart-url-base :as string))
|
||||
(defcomp ~overview/content (&key (page-groups :as list) (cart-url-base :as string))
|
||||
(if (empty? page-groups)
|
||||
(~cart-empty)
|
||||
(~cart-overview-panel
|
||||
(~overview/empty)
|
||||
(~overview/panel
|
||||
: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))))
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
;; 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"
|
||||
(~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
|
||||
:checkout-prefix checkout-prefix :sx-select "#payments-panel")))
|
||||
|
||||
;; Assembled cart admin overview content
|
||||
(defcomp ~cart-admin-content ()
|
||||
(defcomp ~payments/admin-content ()
|
||||
(let* ((payments-href (url-for "defpage_cart_payments")))
|
||||
(div :id "main-panel"
|
||||
(div :class "flex items-center justify-between p-3 border-b"
|
||||
@@ -15,13 +15,13 @@
|
||||
(a :href payments-href :class "text-sm underline" "configure")))))
|
||||
|
||||
;; 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")))
|
||||
(merchant-code (or (get page-config "sumup_merchant_code") ""))
|
||||
(checkout-prefix (or (get page-config "sumup_checkout_prefix") ""))
|
||||
(placeholder (if sumup-configured "--------" "sup_sk_..."))
|
||||
(input-cls "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
||||
(~cart-payments-panel
|
||||
(~payments/panel
|
||||
:update-url (url-for "page_admin.update_sumup")
|
||||
:csrf (csrf-token)
|
||||
:merchant-code merchant-code
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
;; 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"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
||||
(i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") label)))
|
||||
|
||||
(defcomp ~cart-checkout-signin (&key (href :as string))
|
||||
(defcomp ~summary/checkout-signin (&key (href :as string))
|
||||
(div :class "w-full flex"
|
||||
(a :href href :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
|
||||
(i :class "fa-solid fa-key") (span "sign in or register to checkout"))))
|
||||
|
||||
(defcomp ~cart-summary-panel (&key (item-count :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"
|
||||
(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")
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
;; 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))
|
||||
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(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"
|
||||
@@ -35,7 +35,7 @@
|
||||
(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))))))
|
||||
|
||||
(defcomp ~cart-tickets-section (&key items)
|
||||
(defcomp ~tickets/section (&key items)
|
||||
(div :class "mt-6 border-t border-stone-200 pt-4"
|
||||
(h2 :class "text-base font-semibold mb-2"
|
||||
(i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets")
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:auth :public
|
||||
:layout :root
|
||||
:data (service "cart-page" "overview-data")
|
||||
:content (~cart-overview-content
|
||||
:content (~overview/content
|
||||
:page-groups page-groups
|
||||
:cart-url-base cart-url-base))
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
:auth :public
|
||||
:layout :cart-page
|
||||
:data (service "cart-page" "page-cart-data")
|
||||
:content (~cart-page-cart-content
|
||||
:content (~items/page-cart-content
|
||||
:cart-items cart-items
|
||||
:cal-entries cal-entries
|
||||
:ticket-groups ticket-groups
|
||||
:summary (~cart-summary-from-data
|
||||
:summary (~items/summary-from-data
|
||||
:item-count (get summary "item_count")
|
||||
:grand-total (get summary "grand_total")
|
||||
:symbol (get summary "symbol")
|
||||
@@ -33,12 +33,12 @@
|
||||
:auth :admin
|
||||
:layout :cart-admin
|
||||
:data (service "cart-page" "admin-data")
|
||||
:content (~cart-admin-content))
|
||||
:content (~payments/admin-content))
|
||||
|
||||
(defpage cart-payments
|
||||
:path "/<page_slug>/admin/payments/"
|
||||
:auth :admin
|
||||
:layout (:cart-admin :selected "payments")
|
||||
:data (service "cart-page" "payments-admin-data")
|
||||
:content (~cart-payments-content
|
||||
:content (~payments/content
|
||||
:page-config page-config))
|
||||
|
||||
@@ -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]
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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]
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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)
|
||||
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())
|
||||
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,
|
||||
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)
|
||||
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())
|
||||
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,
|
||||
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.infrastructure.urls import cart_url
|
||||
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")
|
||||
content = sx_call("cart-checkout-error-from-data",
|
||||
msg=err_msg, order_id=order.id if order else None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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"
|
||||
(div
|
||||
(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"))))
|
||||
(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"
|
||||
(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 "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
|
||||
content))
|
||||
|
||||
(defcomp ~events-entry-name-field (&key name)
|
||||
(defcomp ~admin/entry-name-field (&key 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"
|
||||
(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)))
|
||||
|
||||
(defcomp ~events-entry-slot-none ()
|
||||
(defcomp ~admin/entry-slot-none ()
|
||||
(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))
|
||||
|
||||
(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)))
|
||||
|
||||
(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)))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~events-entry-date-field (&key date-str)
|
||||
(defcomp ~admin/entry-date-field (&key 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))
|
||||
|
||||
(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)
|
||||
(section :id (str "entry-" entry-id) :class list-container
|
||||
name slot time state cost
|
||||
@@ -68,21 +68,21 @@
|
||||
:sx-get edit-url :sx-target (str "#entry-" entry-id) :sx-swap "outerHTML"
|
||||
"Edit"))))
|
||||
|
||||
(defcomp ~events-entry-title (&key name badge)
|
||||
(defcomp ~admin/entry-title (&key 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))
|
||||
|
||||
(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-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"
|
||||
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)
|
||||
(form :sx-post url :sx-select target :sx-target target :sx-swap "outerHTML"
|
||||
:sx-trigger (if is-btn "confirmed" nil)
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
;; 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
|
||||
: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)))
|
||||
|
||||
(defcomp ~events-calendar-weekday (&key (name :as string))
|
||||
(defcomp ~calendar/weekday (&key (name :as string))
|
||||
(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))
|
||||
|
||||
(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"
|
||||
: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)
|
||||
(span :class "truncate" name)
|
||||
(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 "flex justify-between items-center"
|
||||
(div :class "flex flex-col" day-short day-num))
|
||||
(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"
|
||||
(header :class "flex items-center justify-center mt-2"
|
||||
(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))))
|
||||
|
||||
;; 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)
|
||||
(next-month-href :as string) (next-year-href :as string)
|
||||
(weekday-names :as list) (cells :as list))
|
||||
(~events-calendar-grid
|
||||
(~calendar/grid
|
||||
:arrows (<>
|
||||
(~events-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")
|
||||
(~events-calendar-month-label :month-name month-name :year year)
|
||||
(~events-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"))
|
||||
:weekdays (<> (map (lambda (wd) (~events-calendar-weekday :name wd))
|
||||
(~calendar/nav-arrow :pill-cls pill-cls :href prev-year-href :label "\u00ab")
|
||||
(~calendar/nav-arrow :pill-cls pill-cls :href prev-month-href :label "\u2039")
|
||||
(~calendar/month-label :month-name month-name :year year)
|
||||
(~calendar/nav-arrow :pill-cls pill-cls :href next-month-href :label "\u203a")
|
||||
(~calendar/nav-arrow :pill-cls pill-cls :href next-year-href :label "\u00bb"))
|
||||
:weekdays (<> (map (lambda (wd) (~calendar/weekday :name wd))
|
||||
(or weekday-names (list))))
|
||||
:cells (<> (map (lambda (cell)
|
||||
(~events-calendar-cell
|
||||
(~calendar/cell
|
||||
:cell-cls (get cell "cell-cls")
|
||||
: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")
|
||||
(~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")))
|
||||
:badges (when (get cell "badges")
|
||||
(<> (map (lambda (b)
|
||||
(~events-calendar-entry-badge
|
||||
(~calendar/entry-badge
|
||||
:bg-cls (get b "bg-cls") :name (get b "name")
|
||||
:state-label (get b "state-label")))
|
||||
(get cell "badges"))))))
|
||||
(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"
|
||||
(if 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"
|
||||
(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"
|
||||
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
|
||||
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"
|
||||
(form :sx-post save-url :sx-target "#calendar-description" :sx-swap "outerHTML"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
;; 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"
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(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"
|
||||
:id "day-entries-nav-wrapper"
|
||||
(div :class "flex overflow-x-auto gap-1 scrollbar-thin"
|
||||
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
|
||||
(table :class "w-full text-sm border table-fixed"
|
||||
(thead :class "bg-stone-100"
|
||||
@@ -29,95 +29,95 @@
|
||||
:sx-get add-url :sx-target "#entry-add-container" :sx-swap "innerHTML"
|
||||
"+ Add entry"))))
|
||||
|
||||
(defcomp ~events-day-empty-row ()
|
||||
(defcomp ~day/empty-row ()
|
||||
(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"
|
||||
(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))))
|
||||
|
||||
(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"
|
||||
(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)
|
||||
(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))))
|
||||
|
||||
(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)))
|
||||
|
||||
(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)))
|
||||
|
||||
(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"
|
||||
(div :class "font-medium text-green-600" price-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")))
|
||||
|
||||
(defcomp ~events-day-row-actions ()
|
||||
(defcomp ~day/row-actions ()
|
||||
(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))
|
||||
|
||||
(defcomp ~events-day-admin-panel ()
|
||||
(defcomp ~day/admin-panel ()
|
||||
(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"))
|
||||
|
||||
(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"
|
||||
:id "day-entries-nav-wrapper" :sx-swap-oob "true"
|
||||
(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
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" time-str))))
|
||||
|
||||
;; 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?))
|
||||
(~events-day-table
|
||||
(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?))
|
||||
(~day/table
|
||||
:list-container list-container
|
||||
:rows (if (empty? (or rows (list)))
|
||||
(~events-day-empty-row)
|
||||
(~day/empty-row)
|
||||
(<> (map (lambda (r)
|
||||
(~events-day-row
|
||||
(~day/row
|
||||
: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"))
|
||||
:slot (if (get r "slot-name")
|
||||
(~events-day-row-slot
|
||||
(~day/row-slot
|
||||
:href (get r "slot-href") :pill-cls pill-cls
|
||||
:slot-name (get r "slot-name") :time-str (get r "slot-time"))
|
||||
(~events-day-row-time :start (get r "start") :end (get r "end")))
|
||||
:state (~events-day-row-state
|
||||
(~day/row-time :start (get r "start") :end (get r "end")))
|
||||
:state (~day/row-state
|
||||
:state-id (get r "state-id")
|
||||
:badge (~entry-state-badge :state (get r "state")))
|
||||
:cost (~events-day-row-cost :cost-str (get r "cost-str"))
|
||||
:badge (~entries/entry-state-badge :state (get r "state")))
|
||||
:cost (~day/row-cost :cost-str (get r "cost-str"))
|
||||
: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"))
|
||||
(~events-day-row-no-tickets))
|
||||
:actions (~events-day-row-actions)))
|
||||
(~day/row-no-tickets))
|
||||
:actions (~day/row-actions)))
|
||||
(or rows (list)))))
|
||||
:pre-action pre-action :add-url add-url))
|
||||
|
||||
;; 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)))
|
||||
(~events-day-entries-nav-oob-empty)
|
||||
(~events-day-entries-nav-oob
|
||||
(~day/entries-nav-oob-empty)
|
||||
(~day/entries-nav-oob
|
||||
:items (<> (map (lambda (e)
|
||||
(~events-day-nav-entry
|
||||
(~day/nav-entry
|
||||
:href (get e "href") :nav-btn nav-btn
|
||||
:name (get e "name") :time-str (get e "time-str")))
|
||||
entries)))))
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
;; State badges — cond maps state string to class + label
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~entry-state-badge (&key state)
|
||||
(~badge
|
||||
(defcomp ~entries/entry-state-badge (&key state)
|
||||
(~shared:misc/badge
|
||||
:cls (cond
|
||||
((= state "confirmed") "bg-emerald-100 text-emerald-800")
|
||||
((= state "provisional") "bg-amber-100 text-amber-800")
|
||||
@@ -21,7 +21,7 @@
|
||||
((= state "declined") "Declined")
|
||||
(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 "
|
||||
(cond
|
||||
((= state "confirmed") "bg-emerald-100 text-emerald-800")
|
||||
@@ -38,8 +38,8 @@
|
||||
((= state "declined") "Declined")
|
||||
(true (or state "Unknown")))))
|
||||
|
||||
(defcomp ~ticket-state-badge (&key state)
|
||||
(~badge
|
||||
(defcomp ~entries/ticket-state-badge (&key state)
|
||||
(~shared:misc/badge
|
||||
:cls (cond
|
||||
((= state "confirmed") "bg-emerald-100 text-emerald-800")
|
||||
((= state "checked_in") "bg-blue-100 text-blue-800")
|
||||
@@ -53,7 +53,7 @@
|
||||
((= state "cancelled") "Cancelled")
|
||||
(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 "
|
||||
(cond
|
||||
((= state "confirmed") "bg-emerald-100 text-emerald-800")
|
||||
@@ -73,36 +73,36 @@
|
||||
;; 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"
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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) " · "))
|
||||
|
||||
(defcomp ~events-entry-time-plain (&key date-str)
|
||||
(defcomp ~entries/entry-time-plain (&key 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))
|
||||
|
||||
(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"
|
||||
(div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-3"
|
||||
(div :class "flex-1 min-w-0"
|
||||
@@ -112,7 +112,7 @@
|
||||
cost)
|
||||
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"
|
||||
(div :class "p-3"
|
||||
title
|
||||
@@ -121,20 +121,20 @@
|
||||
cost)
|
||||
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))
|
||||
|
||||
(defcomp ~events-entry-widget-wrapper (&key widget)
|
||||
(defcomp ~entries/entry-widget-wrapper (&key 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"
|
||||
(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))
|
||||
|
||||
(defcomp ~events-main-panel-body (&key toggle body)
|
||||
(defcomp ~entries/main-panel-body (&key toggle body)
|
||||
(<> toggle body (div :class "pb-8")))
|
||||
|
||||
|
||||
@@ -143,46 +143,46 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Ticket widget from data — replaces _ticket_widget_html Python composition
|
||||
(defcomp ~events-tw-widget-from-data (&key entry-id price qty ticket-url csrf)
|
||||
(~events-tw-widget :entry-id (str entry-id) :price price
|
||||
(defcomp ~entries/tw-widget-from-data (&key entry-id price qty ticket-url csrf)
|
||||
(~page/tw-widget :entry-id (str entry-id) :price price
|
||||
: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"
|
||||
: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))
|
||||
:btn (~events-tw-minus))
|
||||
(~events-tw-cart-icon :qty (str qty))
|
||||
(~events-tw-form :ticket-url ticket-url :target (str "#page-ticket-" entry-id)
|
||||
:btn (~page/tw-minus))
|
||||
(~page/tw-cart-icon :qty (str qty))
|
||||
(~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))
|
||||
:btn (~events-tw-plus))))))
|
||||
:btn (~page/tw-plus))))))
|
||||
|
||||
;; 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
|
||||
date-str start-time end-time is-page-scoped
|
||||
cost has-ticket ticket-data)
|
||||
(~events-entry-card
|
||||
(~entries/entry-card
|
||||
:title (if entry-href
|
||||
(~events-entry-title-linked :href entry-href :name name)
|
||||
(~events-entry-title-plain :name name))
|
||||
(~entries/entry-title-linked :href entry-href :name name)
|
||||
(~entries/entry-title-plain :name name))
|
||||
:badges (<>
|
||||
(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
|
||||
(~events-entry-cal-badge :name cal-name)))
|
||||
(~entries/entry-cal-badge :name cal-name)))
|
||||
:time-parts (<>
|
||||
(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)
|
||||
(~events-entry-time-plain :date-str date-str))
|
||||
(~entries/entry-time-plain :date-str date-str))
|
||||
start-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
|
||||
(~events-entry-widget-wrapper
|
||||
:widget (~events-tw-widget-from-data
|
||||
(~entries/entry-widget-wrapper
|
||||
:widget (~entries/tw-widget-from-data
|
||||
:entry-id (get ticket-data "entry-id")
|
||||
:price (get ticket-data "price")
|
||||
:qty (get ticket-data "qty")
|
||||
@@ -190,24 +190,24 @@
|
||||
:csrf (get ticket-data "csrf"))))))
|
||||
|
||||
;; 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
|
||||
date-str time-str
|
||||
cost has-ticket ticket-data)
|
||||
(~events-entry-card-tile
|
||||
(~entries/entry-card-tile
|
||||
:title (if entry-href
|
||||
(~events-entry-title-tile-linked :href entry-href :name name)
|
||||
(~events-entry-title-tile-plain :name name))
|
||||
(~entries/entry-title-tile-linked :href entry-href :name name)
|
||||
(~entries/entry-title-tile-plain :name name))
|
||||
:badges (<>
|
||||
(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
|
||||
(~events-entry-cal-badge :name cal-name)))
|
||||
(~entries/entry-cal-badge :name cal-name)))
|
||||
:time time-str
|
||||
:cost (when cost (~events-entry-cost :cost cost))
|
||||
:cost (when cost (~entries/entry-cost :cost cost))
|
||||
:widget (when has-ticket
|
||||
(~events-entry-tile-widget-wrapper
|
||||
:widget (~events-tw-widget-from-data
|
||||
(~entries/entry-tile-widget-wrapper
|
||||
:widget (~entries/tw-widget-from-data
|
||||
:entry-id (get ticket-data "entry-id")
|
||||
:price (get ticket-data "price")
|
||||
:qty (get ticket-data "qty")
|
||||
@@ -215,13 +215,13 @@
|
||||
:csrf (get ticket-data "csrf"))))))
|
||||
|
||||
;; 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)
|
||||
(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")
|
||||
(~events-entry-card-tile-from-data
|
||||
(~entries/entry-card-tile-from-data
|
||||
:entry-href (get item "entry-href") :name (get item "name")
|
||||
:day-href (get item "day-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")
|
||||
:cost (get item "cost") :has-ticket (get item "has-ticket")
|
||||
: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")
|
||||
:day-href (get item "day-href")
|
||||
:page-badge-href (get item "page-badge-href")
|
||||
@@ -243,20 +243,20 @@
|
||||
:ticket-data (get item "ticket-data")))))
|
||||
(or items (list)))
|
||||
(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
|
||||
(defcomp ~events-main-panel-from-data (&key toggle items view page has-more next-url)
|
||||
(~events-main-panel-body
|
||||
(defcomp ~entries/main-panel-from-data (&key toggle items view page has-more next-url)
|
||||
(~entries/main-panel-body
|
||||
:toggle toggle
|
||||
:body (if items
|
||||
(~events-grid
|
||||
(~entries/grid
|
||||
:grid-cls (if (= view "tile")
|
||||
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||
"max-w-full px-3 py-3 space-y-3")
|
||||
:cards (~events-entry-cards-from-data
|
||||
:cards (~entries/entry-cards-from-data
|
||||
:items items :view view :page page
|
||||
: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"
|
||||
:cls "px-3 py-12 text-center text-stone-400"))))
|
||||
|
||||
@@ -5,25 +5,25 @@
|
||||
;; 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
|
||||
:data-flexible data-flexible :data-cost data-cost
|
||||
:selected selected
|
||||
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"
|
||||
:data-slot-picker "" :required "required"
|
||||
options))
|
||||
|
||||
(defcomp ~events-no-slots ()
|
||||
(defcomp ~forms/no-slots ()
|
||||
(div :class "text-sm text-stone-500" "No slots defined for this day."))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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
|
||||
start-val end-val cost-display
|
||||
ticket-price-val ticket-count-val
|
||||
@@ -115,7 +115,7 @@
|
||||
;; 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)
|
||||
(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"
|
||||
@@ -129,7 +129,7 @@
|
||||
:data-confirm-cancel-text "Cancel"
|
||||
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)
|
||||
:sx-get next-url
|
||||
: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-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"))
|
||||
|
||||
|
||||
@@ -180,17 +180,17 @@
|
||||
;; 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"
|
||||
(input :type "checkbox" :name name :value "1" :data-day name :checked checked)
|
||||
(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"
|
||||
(input :type "checkbox" :data-day-all "" :checked checked)
|
||||
(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
|
||||
days flexible-checked
|
||||
action-btn cancel-btn)
|
||||
@@ -271,7 +271,7 @@
|
||||
;; 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"
|
||||
:sx-disinherit "sx-select" :sx-swap "outerHTML"
|
||||
:sx-headers csrf :class "space-y-3"
|
||||
@@ -312,7 +312,7 @@
|
||||
:data-confirm-cancel-text "Cancel"
|
||||
(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
|
||||
:sx-get add-url :sx-target "#slot-add-container" :sx-swap "innerHTML"
|
||||
"+ Add slot"))
|
||||
@@ -323,20 +323,20 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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)
|
||||
(~events-day-checkbox
|
||||
(~forms/day-checkbox
|
||||
:name (get d "name")
|
||||
:label (get d "label")
|
||||
:checked (when (get d "checked") "checked")))
|
||||
(or days-data (list)))))
|
||||
|
||||
;; 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)
|
||||
(~events-slot-option
|
||||
(~forms/slot-option
|
||||
:value (get s "value")
|
||||
:data-start (get s "data-start")
|
||||
:data-end (get s "data-end")
|
||||
@@ -347,32 +347,32 @@
|
||||
(or slots (list)))))
|
||||
|
||||
;; 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)))
|
||||
(~events-no-slots)
|
||||
(~events-slot-picker
|
||||
(~forms/no-slots)
|
||||
(~forms/slot-picker
|
||||
:id id
|
||||
:options (~events-slot-options-from-data :slots slots))))
|
||||
:options (~forms/slot-options-from-data :slots slots))))
|
||||
|
||||
;; 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
|
||||
days-data all-checked flexible-checked
|
||||
action-btn cancel-btn)
|
||||
(~events-slot-edit-form
|
||||
(~forms/slot-edit-form
|
||||
:slot-id slot-id :list-container list-container
|
||||
:put-url put-url :cancel-url cancel-url :csrf csrf
|
||||
:name-val name-val :cost-val cost-val :start-val start-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
|
||||
:action-btn action-btn :cancel-btn cancel-btn))
|
||||
|
||||
;; Slot add form from data
|
||||
(defcomp ~events-slot-add-form-from-data (&key post-url csrf days-data action-btn cancel-btn cancel-url)
|
||||
(~events-slot-add-form
|
||||
(defcomp ~forms/slot-add-form-from-data (&key post-url csrf days-data action-btn cancel-btn cancel-url)
|
||||
(~forms/slot-add-form
|
||||
: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))
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@
|
||||
;; 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)
|
||||
(<>
|
||||
(div :id "entry-errors" :class "mt-2 text-sm text-red-600")
|
||||
@@ -446,7 +446,7 @@
|
||||
:data-confirm-cancel-text "Cancel"
|
||||
(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
|
||||
:sx-get add-url :sx-target "#entry-add-container" :sx-swap "innerHTML"
|
||||
"+ Add entry"))
|
||||
@@ -456,7 +456,7 @@
|
||||
;; 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
|
||||
action-btn cancel-btn)
|
||||
(section :id (str "ticket-" ticket-id) :class list-container
|
||||
@@ -509,7 +509,7 @@
|
||||
;; 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"
|
||||
:sx-disinherit "sx-select" :sx-swap "outerHTML"
|
||||
:sx-headers csrf :class "space-y-3"
|
||||
@@ -540,7 +540,7 @@
|
||||
:data-confirm-cancel-text "Cancel"
|
||||
(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
|
||||
:sx-get add-url :sx-target "#ticket-add-container" :sx-swap "innerHTML"
|
||||
(i :class "fa fa-plus") " Add ticket type"))
|
||||
@@ -550,6 +550,6 @@
|
||||
;; Entry admin nav — placeholder
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-admin-placeholder-nav ()
|
||||
(defcomp ~forms/admin-placeholder-nav ()
|
||||
(div :class "relative nav-group"
|
||||
(span :class "block px-3 py-2 text-stone-400 text-sm italic" "Admin options")))
|
||||
@@ -5,14 +5,14 @@
|
||||
;; 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
|
||||
: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 "text-xs text-stone-600" date-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"
|
||||
(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;"
|
||||
@@ -23,7 +23,7 @@
|
||||
;; 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 "flex items-start justify-between gap-4"
|
||||
(div :class "min-w-0 flex-1"
|
||||
@@ -35,13 +35,13 @@
|
||||
type-name))
|
||||
(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 "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")
|
||||
items)))
|
||||
|
||||
(defcomp ~events-frag-tickets-list (&key items)
|
||||
(defcomp ~fragments/frag-tickets-list (&key items)
|
||||
(div :class "divide-y divide-stone-100" items))
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
;; 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 "flex items-start justify-between gap-4"
|
||||
(div :class "min-w-0 flex-1"
|
||||
@@ -60,13 +60,13 @@
|
||||
cost-str))
|
||||
(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 "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")
|
||||
items)))
|
||||
|
||||
(defcomp ~events-frag-bookings-list (&key items)
|
||||
(defcomp ~fragments/frag-bookings-list (&key items)
|
||||
(div :class "divide-y divide-stone-100" items))
|
||||
|
||||
|
||||
@@ -75,12 +75,12 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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)
|
||||
(if (get w "entries")
|
||||
(~events-frag-entries-widget
|
||||
(~fragments/frag-entries-widget
|
||||
:cards (<> (map (lambda (e)
|
||||
(~events-frag-entry-card
|
||||
(~fragments/frag-entry-card
|
||||
:href (get e "href") :name (get e "name")
|
||||
:date-str (get e "date-str") :time-str (get e "time-str")))
|
||||
(get w "entries"))))
|
||||
@@ -88,43 +88,43 @@
|
||||
(or widgets (list)))))
|
||||
|
||||
;; 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)
|
||||
(~events-frag-ticket-item
|
||||
(defcomp ~fragments/frag-ticket-item-from-data (&key href entry-name date-str calendar-name type-name state)
|
||||
(~fragments/frag-ticket-item
|
||||
:href href :entry-name entry-name :date-str date-str
|
||||
:calendar-name (when calendar-name (span "\u00b7 " calendar-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
|
||||
(defcomp ~events-frag-tickets-panel-from-data (&key tickets)
|
||||
(~events-frag-tickets-panel
|
||||
(defcomp ~fragments/frag-tickets-panel-from-data (&key tickets)
|
||||
(~fragments/frag-tickets-panel
|
||||
:items (if (empty? (or tickets (list)))
|
||||
(~empty-state :message "No tickets yet." :cls "text-sm text-stone-500")
|
||||
(~events-frag-tickets-list
|
||||
(~shared:misc/empty-state :message "No tickets yet." :cls "text-sm text-stone-500")
|
||||
(~fragments/frag-tickets-list
|
||||
: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")
|
||||
:date-str (get t "date-str") :calendar-name (get t "calendar-name")
|
||||
:type-name (get t "type-name") :state (get t "state")))
|
||||
tickets))))))
|
||||
|
||||
;; 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)
|
||||
(~events-frag-booking-item
|
||||
(defcomp ~fragments/frag-booking-item-from-data (&key name date-str end-time calendar-name cost-str state)
|
||||
(~fragments/frag-booking-item
|
||||
:name name
|
||||
:date-str (<> date-str (when end-time (span "\u2013 " end-time)))
|
||||
:calendar-name (when calendar-name (span "\u00b7 " calendar-name))
|
||||
: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
|
||||
(defcomp ~events-frag-bookings-panel-from-data (&key bookings)
|
||||
(~events-frag-bookings-panel
|
||||
(defcomp ~fragments/frag-bookings-panel-from-data (&key bookings)
|
||||
(~fragments/frag-bookings-panel
|
||||
:items (if (empty? (or bookings (list)))
|
||||
(~empty-state :message "No bookings yet." :cls "text-sm text-stone-500")
|
||||
(~events-frag-bookings-list
|
||||
(~shared:misc/empty-state :message "No bookings yet." :cls "text-sm text-stone-500")
|
||||
(~fragments/frag-bookings-list
|
||||
:items (<> (map (lambda (b)
|
||||
(~events-frag-booking-item-from-data
|
||||
(~fragments/frag-booking-item-from-data
|
||||
:href (get b "href") :name (get b "name")
|
||||
:date-str (get b "date-str") :end-time (get b "end-time")
|
||||
:calendar-name (get b "calendar-name") :cost-str (get b "cost-str")
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
(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"))
|
||||
(<>
|
||||
(~nav-group-link
|
||||
(~shared:misc/nav-group-link
|
||||
:href (app-url "account" "/tickets/")
|
||||
:hx-select hx-select
|
||||
:nav-class nav-class
|
||||
:label "tickets")
|
||||
(~nav-group-link
|
||||
(~shared:misc/nav-group-link
|
||||
:href (app-url "account" "/bookings/")
|
||||
:hx-select hx-select
|
||||
:nav-class nav-class
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
(cond
|
||||
(= slug "tickets")
|
||||
(let ((tickets (service "calendar" "user-tickets" :user-id uid)))
|
||||
(~events-frag-tickets-panel
|
||||
(~fragments/frag-tickets-panel
|
||||
:items (if (empty? tickets)
|
||||
(~empty-state :message "No tickets yet."
|
||||
(~shared:misc/empty-state :message "No tickets yet."
|
||||
:cls "text-sm text-stone-500")
|
||||
(~events-frag-tickets-list
|
||||
(~fragments/frag-tickets-list
|
||||
:items (<> (map (fn (t)
|
||||
(~events-frag-ticket-item
|
||||
(~fragments/frag-ticket-item
|
||||
:href (app-url "events"
|
||||
(str "/tickets/" (get t "code") "/"))
|
||||
:entry-name (get t "entry_name")
|
||||
@@ -25,18 +25,18 @@
|
||||
(span (str "\u00b7 " (get t "calendar_name"))))
|
||||
:type-name (when (get t "ticket_type_name")
|
||||
(span (str "\u00b7 " (get t "ticket_type_name"))))
|
||||
:badge (~status-pill :status (or (get t "state") ""))))
|
||||
:badge (~shared:controls/status-pill :status (or (get t "state") ""))))
|
||||
tickets))))))
|
||||
|
||||
(= slug "bookings")
|
||||
(let ((bookings (service "calendar" "user-bookings" :user-id uid)))
|
||||
(~events-frag-bookings-panel
|
||||
(~fragments/frag-bookings-panel
|
||||
:items (if (empty? bookings)
|
||||
(~empty-state :message "No bookings yet."
|
||||
(~shared:misc/empty-state :message "No bookings yet."
|
||||
:cls "text-sm text-stone-500")
|
||||
(~events-frag-bookings-list
|
||||
(~fragments/frag-bookings-list
|
||||
:items (<> (map (fn (b)
|
||||
(~events-frag-booking-item
|
||||
(~fragments/frag-booking-item
|
||||
:name (get b "name")
|
||||
:date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M")
|
||||
(if (get b "end_at")
|
||||
@@ -46,5 +46,5 @@
|
||||
(span (str "\u00b7 " (get b "calendar_name"))))
|
||||
:cost-str (when (get b "cost")
|
||||
(span (str "\u00b7 \u00a3" (get b "cost"))))
|
||||
:badge (~status-pill :status (or (get b "state") ""))))
|
||||
:badge (~shared:controls/status-pill :status (or (get b "state") ""))))
|
||||
bookings))))))))))
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
(post-slug (or (nth slugs i) "")))
|
||||
(<> (str "<!-- card-widget:" pid " -->")
|
||||
(when (not (empty? entries))
|
||||
(~events-frag-entries-widget
|
||||
(~fragments/frag-entries-widget
|
||||
:cards (<> (map (fn (e)
|
||||
(let ((time-str (str (format-date (get e "start_at") "%H:%M")
|
||||
(if (get e "end_at")
|
||||
(str " \u2013 " (format-date (get e "end_at") "%H:%M"))
|
||||
""))))
|
||||
(~events-frag-entry-card
|
||||
(~fragments/frag-entry-card
|
||||
:href (app-url "events"
|
||||
(str "/" post-slug
|
||||
"/" (get e "calendar_slug")
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
(if (get entry "end_at")
|
||||
(str " – " (format-date (get entry "end_at") "%H:%M"))
|
||||
""))))
|
||||
(~calendar-entry-nav
|
||||
(~shared:navigation/calendar-entry-nav
|
||||
:href (app-url "events" entry-path)
|
||||
:name (get entry "name")
|
||||
:date-str date-str
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
;; Infinite scroll sentinel
|
||||
(when (and has-more (not (empty? purl)))
|
||||
(~htmx-sentinel
|
||||
(~shared:misc/htmx-sentinel
|
||||
:id (str "entries-load-sentinel-" pg)
|
||||
:hx-get (str purl "?page=" (+ pg 1))
|
||||
:hx-trigger "intersect once"
|
||||
@@ -74,7 +74,7 @@
|
||||
(is-selected (if (not (empty? cur-cal))
|
||||
(= (get cal "slug") cur-cal)
|
||||
false)))
|
||||
(~calendar-link-nav
|
||||
(~shared:navigation/calendar-link-nav
|
||||
:href href
|
||||
:name (get cal "name")
|
||||
:nav-class nav-class
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
:container-type "page"
|
||||
:container-id (get post "id")))
|
||||
(cal-names (join ", " (map (fn (c) (get c "name")) calendars))))
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
:subtitle cal-names
|
||||
@@ -28,7 +28,7 @@
|
||||
:container-type "page"
|
||||
:container-id (get post "id")))
|
||||
(cal-names (join ", " (map (fn (c) (get c "name")) calendars))))
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:title (get post "title")
|
||||
:image (get post "feature_image")
|
||||
:subtitle cal-names
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
;; Events header components
|
||||
|
||||
(defcomp ~events-calendars-label ()
|
||||
(defcomp ~header/calendars-label ()
|
||||
(<> (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")))
|
||||
|
||||
(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-row items-center gap-2"
|
||||
(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"
|
||||
description)))
|
||||
|
||||
(defcomp ~events-day-label (&key date-str)
|
||||
(defcomp ~header/day-label (&key date-str)
|
||||
(div :class "flex gap-1 items-center"
|
||||
(i :class "fa fa-calendar-day")
|
||||
(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"
|
||||
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-row items-center gap-2"
|
||||
(i :class "fa fa-clock")
|
||||
|
||||
@@ -11,20 +11,20 @@
|
||||
(let ((__cal (events-calendar-ctx))
|
||||
(__sc (select-colours)))
|
||||
(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"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
:link-label-content (~events-calendar-label
|
||||
:link-label-content (~header/calendar-label
|
||||
:name (get __cal "name")
|
||||
:description (get __cal "description"))
|
||||
:nav (<>
|
||||
(~nav-link :href (url-for "defpage_slots_listing"
|
||||
(~shared:layout/nav-link :href (url-for "defpage_slots_listing"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
:icon "fa fa-clock" :label "Slots"
|
||||
:select-colours __sc)
|
||||
(let ((__rights (app-rights)))
|
||||
(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"))
|
||||
:icon "fa fa-cog"
|
||||
:select-colours __sc))))
|
||||
@@ -37,13 +37,13 @@
|
||||
(let ((__cal (events-calendar-ctx))
|
||||
(__sc (select-colours)))
|
||||
(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"
|
||||
:nav (<>
|
||||
(~nav-link :href (url-for "defpage_slots_listing"
|
||||
(~shared:layout/nav-link :href (url-for "defpage_slots_listing"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
: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"))
|
||||
:label "description" :select-colours __sc))
|
||||
:child-id "calendar-admin-header-child"
|
||||
@@ -55,13 +55,13 @@
|
||||
(let ((__day (events-day-ctx))
|
||||
(__cal (events-calendar-ctx)))
|
||||
(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"
|
||||
:calendar-slug (get __cal "slug")
|
||||
:year (get __day "year")
|
||||
:month (get __day "month")
|
||||
:day (get __day "day"))
|
||||
:link-label-content (~events-day-label
|
||||
:link-label-content (~header/day-label
|
||||
:date-str (get __day "date-str"))
|
||||
:nav (get __day "nav")
|
||||
:child-id "day-header-child"
|
||||
@@ -73,7 +73,7 @@
|
||||
(let ((__day (events-day-ctx))
|
||||
(__cal (events-calendar-ctx)))
|
||||
(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"
|
||||
:calendar-slug (get __cal "slug")
|
||||
:year (get __day "year")
|
||||
@@ -88,12 +88,12 @@
|
||||
(quasiquote
|
||||
(let ((__ectx (events-entry-ctx)))
|
||||
(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-label-content (~events-entry-label
|
||||
:link-label-content (~header/entry-label
|
||||
:entry-id (get __ectx "id")
|
||||
:title (~events-entry-title :name (get __ectx "name"))
|
||||
:times (~events-entry-times :time-str (get __ectx "time-str")))
|
||||
:title (~admin/entry-title :name (get __ectx "name"))
|
||||
:times (~admin/entry-times :time-str (get __ectx "time-str")))
|
||||
:nav (get __ectx "nav")
|
||||
:child-id "entry-header-child"
|
||||
:oob (unquote oob))))))
|
||||
@@ -103,11 +103,11 @@
|
||||
(quasiquote
|
||||
(let ((__ectx (events-entry-ctx)))
|
||||
(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-label "admin" :icon "fa fa-cog"
|
||||
: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"
|
||||
:select-colours (get __ectx "select-colours")))
|
||||
:child-id "entry-admin-header-child"
|
||||
@@ -118,8 +118,8 @@
|
||||
(quasiquote
|
||||
(let ((__slot (events-slot-ctx)))
|
||||
(when (get __slot "name")
|
||||
(~menu-row-sx :id "slot-row" :level 5
|
||||
:link-label-content (~events-slot-label
|
||||
(~shared:layout/menu-row-sx :id "slot-row" :level 5
|
||||
:link-label-content (~header/slot-label
|
||||
:name (get __slot "name")
|
||||
:description (get __slot "description"))
|
||||
:child-id "slot-header-child"
|
||||
@@ -131,12 +131,12 @@
|
||||
(let ((__ectx (events-entry-ctx))
|
||||
(__cal (events-calendar-ctx)))
|
||||
(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-label-content (<>
|
||||
(i :class "fa fa-ticket")
|
||||
(div :class "shrink-0" "ticket types"))
|
||||
:nav (~events-admin-placeholder-nav)
|
||||
:nav (~forms/admin-placeholder-nav)
|
||||
:child-id "ticket_type-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
@@ -145,22 +145,22 @@
|
||||
(quasiquote
|
||||
(let ((__tt (events-ticket-type-ctx)))
|
||||
(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-label-content (div :class "flex flex-col md:flex-row md:gap-2 items-center"
|
||||
(div :class "flex flex-row items-center gap-2"
|
||||
(i :class "fa fa-ticket")
|
||||
(div :class "shrink-0" (get __tt "name"))))
|
||||
:nav (~events-admin-placeholder-nav)
|
||||
:nav (~forms/admin-placeholder-nav)
|
||||
:child-id "ticket_type-header-child-inner"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-markets-header-auto (oob)
|
||||
"Markets section header row."
|
||||
(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-label-content (~events-markets-label)
|
||||
:link-label-content (~header/markets-label)
|
||||
:child-id "markets-header-child"
|
||||
:oob (unquote oob))))
|
||||
|
||||
@@ -168,218 +168,218 @@
|
||||
;; 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-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row")
|
||||
(~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row")
|
||||
(~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "day-row")
|
||||
(~clear-oob-div :id "day-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")))
|
||||
(~shared:layout/clear-oob-div :id "entry-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "entry-row")
|
||||
(~shared:layout/clear-oob-div :id "entry-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-row")
|
||||
(~shared:layout/clear-oob-div :id "day-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendars-row")
|
||||
(~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-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row")
|
||||
(~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row")
|
||||
(~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "day-row")
|
||||
(~clear-oob-div :id "day-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")))
|
||||
(~shared:layout/clear-oob-div :id "entry-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "entry-row")
|
||||
(~shared:layout/clear-oob-div :id "entry-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-row")
|
||||
(~shared:layout/clear-oob-div :id "day-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendars-row")
|
||||
(~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-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row")
|
||||
(~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")))
|
||||
(~shared:layout/clear-oob-div :id "entry-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "entry-row")
|
||||
(~shared:layout/clear-oob-div :id "entry-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendars-row")
|
||||
(~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-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "day-admin-row")
|
||||
(~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "calendar-admin-row")
|
||||
(~clear-oob-div :id "calendar-admin-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~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 "entry-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendar-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "calendar-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendars-row")
|
||||
(~shared:layout/clear-oob-div :id "calendars-header-child")
|
||||
(~shared:layout/clear-oob-div :id "post-admin-row")
|
||||
(~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-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")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
|
||||
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
|
||||
(~clear-oob-div :id "calendar-row") (~clear-oob-div :id "calendar-header-child")
|
||||
(~clear-oob-div :id "calendars-row") (~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 "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendar-row") (~shared:layout/clear-oob-div :id "calendar-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-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-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
|
||||
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
|
||||
(~clear-oob-div :id "calendar-row") (~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 "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendar-row") (~shared:layout/clear-oob-div :id "calendar-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-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "day-row") (~clear-oob-div :id "day-header-child")
|
||||
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
|
||||
(~clear-oob-div :id "calendars-row") (~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 "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-row") (~shared:layout/clear-oob-div :id "day-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-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-oob-div :id "entry-admin-row") (~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row") (~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row") (~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "calendar-admin-row") (~clear-oob-div :id "calendar-admin-header-child")
|
||||
(~clear-oob-div :id "calendars-row") (~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 "entry-admin-row") (~shared:layout/clear-oob-div :id "entry-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "entry-row") (~shared:layout/clear-oob-div :id "entry-header-child")
|
||||
(~shared:layout/clear-oob-div :id "day-admin-row") (~shared:layout/clear-oob-div :id "day-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendar-admin-row") (~shared:layout/clear-oob-div :id "calendar-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "calendars-row") (~shared:layout/clear-oob-div :id "calendars-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)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-cal-admin-layout-full ()
|
||||
(defcomp ~layouts/cal-admin-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-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")
|
||||
(~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))
|
||||
(~events-clear-oob-cal-admin)
|
||||
(~layouts/clear-oob-cal-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Slots layout: same full as cal-admin
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-slots-layout-full ()
|
||||
(defcomp ~layouts/slots-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-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")
|
||||
(~events-calendar-admin-header-auto true)
|
||||
(~events-clear-oob-cal-admin)
|
||||
(~layouts/clear-oob-cal-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Slot detail layout: root + post + child(admin + cal + cal-admin + slot)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-slot-layout-full ()
|
||||
(defcomp ~layouts/slot-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-calendar-admin-header-auto nil)
|
||||
(~events-slot-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-slot-layout-oob ()
|
||||
(defcomp ~layouts/slot-layout-oob ()
|
||||
(<> (~post-admin-header-auto true "calendars")
|
||||
(~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))
|
||||
(~events-clear-oob-slot)
|
||||
(~layouts/clear-oob-slot)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-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")
|
||||
(~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))
|
||||
(~events-clear-oob-day-admin)
|
||||
(~layouts/clear-oob-day-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Entry layout: root + child(post + cal + day + entry) — public, no admin
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-entry-layout-full ()
|
||||
(defcomp ~layouts/entry-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
(~events-entry-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-entry-layout-oob ()
|
||||
(defcomp ~layouts/entry-layout-oob ()
|
||||
(<> (~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))
|
||||
(~events-clear-oob-entry)
|
||||
(~layouts/clear-oob-entry)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
@@ -387,21 +387,21 @@
|
||||
(~events-entry-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")
|
||||
(~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))
|
||||
(~events-clear-oob-entry-admin)
|
||||
(~layouts/clear-oob-entry-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
@@ -409,9 +409,9 @@
|
||||
(~events-entry-admin-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)
|
||||
(~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))
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -419,9 +419,9 @@
|
||||
;; Ticket type layout: all headers down to ticket-type
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-ticket-type-layout-full ()
|
||||
(defcomp ~layouts/ticket-type-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
@@ -430,9 +430,9 @@
|
||||
(~events-ticket-types-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)
|
||||
(~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))
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -440,14 +440,14 @@
|
||||
;; Markets layout: root + child(post + markets)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-markets-layout-full ()
|
||||
(defcomp ~layouts/markets-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-markets-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-markets-layout-oob ()
|
||||
(defcomp ~layouts/markets-layout-oob ()
|
||||
(<> (~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))
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
;; 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))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~events-slot-no-days ()
|
||||
(defcomp ~page/slot-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
|
||||
(div :class "flex flex-col"
|
||||
(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
|
||||
: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"
|
||||
:class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
|
||||
description))
|
||||
|
||||
(defcomp ~events-slots-empty-row ()
|
||||
(defcomp ~page/slots-empty-row ()
|
||||
(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)
|
||||
(tr :class tr-cls
|
||||
(td :class "p-2 align-top w-1/6"
|
||||
@@ -57,7 +57,7 @@
|
||||
:sx-swap "outerHTML" :sx-headers csrf-hdr :sx-trigger "confirmed"
|
||||
(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
|
||||
(table :class "w-full text-sm border table-fixed"
|
||||
(thead :class "bg-stone-100"
|
||||
@@ -78,61 +78,61 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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)))
|
||||
(~events-slot-no-days)
|
||||
(~events-slot-days-pills
|
||||
:days-inner (<> (map (lambda (d) (~events-slot-day-pill :day d)) days)))))
|
||||
(~page/slot-no-days)
|
||||
(~page/slot-days-pills
|
||||
:days-inner (<> (map (lambda (d) (~page/slot-day-pill :day d)) days)))))
|
||||
|
||||
;; 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
|
||||
pre-action edit-url description oob)
|
||||
(<>
|
||||
(~events-slot-panel
|
||||
(~page/slot-panel
|
||||
: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
|
||||
:pre-action pre-action :edit-url edit-url)
|
||||
(when oob
|
||||
(~events-slot-description-oob :description (or description "")))))
|
||||
(~page/slot-description-oob :description (or description "")))))
|
||||
|
||||
;; 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)
|
||||
(~events-slots-table
|
||||
(~page/slots-table
|
||||
:list-container list-container
|
||||
:rows (if (empty? (or slots (list)))
|
||||
(~events-slots-empty-row)
|
||||
(~page/slots-empty-row)
|
||||
(<> (map (lambda (s)
|
||||
(~events-slots-row
|
||||
(~page/slots-row
|
||||
:tr-cls tr-cls :slot-href (get s "slot-href")
|
||||
:pill-cls pill-cls :hx-select hx-select
|
||||
:slot-name (get s "slot-name") :description (get s "description")
|
||||
: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")
|
||||
:cost-str (get s "cost-str") :action-btn action-btn
|
||||
:del-url (get s "del-url") :csrf-hdr csrf-hdr))
|
||||
(or slots (list)))))
|
||||
: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 "text-xs font-semibold uppercase tracking-wide text-stone-500" label)
|
||||
(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
|
||||
(div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm"
|
||||
c1 c2 c3)
|
||||
(button :type "button" :class pre-action :sx-get edit-url
|
||||
: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.")))
|
||||
|
||||
(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)
|
||||
(tr :class tr-cls
|
||||
(td :class "p-2 align-top w-1/3"
|
||||
@@ -151,7 +151,7 @@
|
||||
:sx-swap "outerHTML" :sx-headers csrf-hdr :sx-trigger "confirmed"
|
||||
(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
|
||||
(table :class "w-full text-sm border table-fixed"
|
||||
(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"
|
||||
(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 "flex items-center gap-2"
|
||||
(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"
|
||||
: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"
|
||||
(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"
|
||||
: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")
|
||||
:sx-post update-url :sx-target (str "#entry-tickets-" entry-id) :sx-swap "innerHTML"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
@@ -203,12 +203,12 @@
|
||||
:onclick hide-js "Cancel"))))
|
||||
|
||||
;; 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
|
||||
user-ticket-count price-str adjust-url csrf state
|
||||
my-tickets-href)
|
||||
(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))
|
||||
(target (str "#ticket-buy-" entry-id)))
|
||||
(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 (div :class "font-medium text-sm" (get tt "name"))
|
||||
(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
|
||||
:my-tickets-href my-tickets-href))))
|
||||
ticket-types))
|
||||
(<> (div :class "flex items-center justify-between mb-4"
|
||||
(div (span :class "font-medium text-green-600" price-str)
|
||||
(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)
|
||||
:ticket-type-id nil :my-tickets-href my-tickets-href)))))))
|
||||
|
||||
;; 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)
|
||||
(form :sx-post adjust-url :sx-target target :sx-swap "outerHTML" :class "flex items-center"
|
||||
(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"
|
||||
"+")))))
|
||||
|
||||
(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"
|
||||
(i :class "fa fa-ticket mr-1" :aria-hidden "true")
|
||||
"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))
|
||||
(suffix (if (= count 1) "" "s")))
|
||||
(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")))))
|
||||
|
||||
;; 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)
|
||||
(<>
|
||||
(~events-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/cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
|
||||
(~page/buy-result :entry-id entry-id :tickets tickets :remaining remaining
|
||||
: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
|
||||
ticket-types user-ticket-counts-by-type
|
||||
user-ticket-count price-str adjust-url csrf state
|
||||
my-tickets-href)
|
||||
(<>
|
||||
(~events-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/cart-icon :cart-count cart-count :blog-href blog-href :cart-href cart-href :logo logo)
|
||||
(~page/buy-form :entry-id entry-id :info-sold info-sold :info-remaining info-remaining
|
||||
:info-basket info-basket :ticket-types ticket-types
|
||||
:user-ticket-counts-by-type user-ticket-counts-by-type
|
||||
:user-ticket-count user-ticket-count :price-str price-str
|
||||
@@ -330,18 +330,18 @@
|
||||
:my-tickets-href my-tickets-href)))
|
||||
|
||||
;; 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)
|
||||
(~events-cart-icon-logo :blog-href blog-href :logo logo)
|
||||
(~events-cart-icon-badge :cart-href cart-href :count (str cart-count))))
|
||||
(~page/cart-icon-logo :blog-href blog-href :logo logo)
|
||||
(~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 :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"
|
||||
(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"
|
||||
(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")
|
||||
@@ -349,37 +349,37 @@
|
||||
count))))
|
||||
|
||||
;; 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"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "entry_id" :value entry-id)
|
||||
(input :type "hidden" :name "count" :value count-val)
|
||||
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"
|
||||
(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" "-"))
|
||||
|
||||
(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" "+"))
|
||||
|
||||
(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"
|
||||
(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 "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"
|
||||
(span :class "text-green-600 font-medium text-sm" price)
|
||||
inner))
|
||||
|
||||
;; 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"
|
||||
posts
|
||||
(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")
|
||||
(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))
|
||||
|
||||
(defcomp ~events-entry-posts-none ()
|
||||
(defcomp ~page/entry-posts-none ()
|
||||
(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"
|
||||
img (span :class "text-sm flex-1" title)
|
||||
(button :type "button" :class "text-xs text-red-600 hover:text-red-800 flex-shrink-0"
|
||||
@@ -409,41 +409,41 @@
|
||||
:sx-headers csrf-hdr
|
||||
(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"))
|
||||
|
||||
(defcomp ~events-post-img-placeholder ()
|
||||
(defcomp ~page/post-img-placeholder ()
|
||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))
|
||||
|
||||
;; 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"))
|
||||
|
||||
(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"
|
||||
:id "entry-posts-nav-wrapper" :sx-swap-oob "true"
|
||||
(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))))
|
||||
|
||||
;; 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"))
|
||||
|
||||
(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
|
||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "font-medium truncate" name)
|
||||
(div :class "text-xs text-stone-600 truncate" 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
|
||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
||||
(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"
|
||||
: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"
|
||||
@@ -461,7 +461,7 @@
|
||||
(i :class "fa fa-chevron-right"))))
|
||||
|
||||
;; 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"
|
||||
img (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title))))
|
||||
|
||||
@@ -471,60 +471,60 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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
|
||||
(~events-post-img :src src :alt alt)
|
||||
(~events-post-img-placeholder)))
|
||||
(~page/post-img :src src :alt alt)
|
||||
(~page/post-img-placeholder)))
|
||||
|
||||
;; 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)))
|
||||
(~events-entry-posts-nav-oob-empty)
|
||||
(~events-entry-posts-nav-oob
|
||||
(~page/entry-posts-nav-oob-empty)
|
||||
(~page/entry-posts-nav-oob
|
||||
:items (<> (map (lambda (p)
|
||||
(~events-entry-nav-post
|
||||
(~page/entry-nav-post
|
||||
: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")))
|
||||
posts)))))
|
||||
|
||||
;; 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))))
|
||||
(~events-entry-posts-nav-oob
|
||||
(~page/entry-posts-nav-oob
|
||||
:items (<> (map (lambda (p)
|
||||
(~events-entry-nav-post-link
|
||||
(~page/entry-nav-post-link
|
||||
: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")))
|
||||
posts)))))
|
||||
|
||||
;; 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))))
|
||||
(~events-post-nav-oob-empty)
|
||||
(~events-post-nav-wrapper
|
||||
(~page/post-nav-oob-empty)
|
||||
(~page/post-nav-wrapper
|
||||
:items (<>
|
||||
(map (lambda (e)
|
||||
(~events-post-nav-entry
|
||||
(~page/post-nav-entry
|
||||
:href (get e "href") :nav-btn nav-btn
|
||||
:name (get e "name") :time-str (get e "time-str")))
|
||||
(or entries (list)))
|
||||
(map (lambda (c)
|
||||
(~events-post-nav-calendar
|
||||
(~page/post-nav-calendar
|
||||
:href (get c "href") :nav-btn nav-btn :name (get c "name")))
|
||||
(or calendars (list))))
|
||||
:hyperscript hyperscript)))
|
||||
|
||||
;; Entry posts panel from data
|
||||
(defcomp ~events-entry-posts-panel-from-data (&key entry-id posts search-url)
|
||||
(~events-entry-posts-panel
|
||||
(defcomp ~page/entry-posts-panel-from-data (&key entry-id posts search-url)
|
||||
(~page/entry-posts-panel
|
||||
:posts (if (empty? (or posts (list)))
|
||||
(~events-entry-posts-none)
|
||||
(~events-entry-posts-list
|
||||
(~page/entry-posts-none)
|
||||
(~page/entry-posts-list
|
||||
:items (<> (map (lambda (p)
|
||||
(~events-entry-post-item
|
||||
:img (~events-post-img-from-data :src (get p "img") :alt (get p "title"))
|
||||
(~page/entry-post-item
|
||||
:img (~page/post-img-from-data :src (get p "img") :alt (get p "title"))
|
||||
:title (get p "title")
|
||||
:del-url (get p "del-url") :entry-id entry-id
|
||||
:csrf-hdr (get p "csrf-hdr")))
|
||||
@@ -532,11 +532,11 @@
|
||||
:search-url search-url :entry-id entry-id))
|
||||
|
||||
;; 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)))
|
||||
(~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)
|
||||
(~crud-item
|
||||
(~shared:misc/crud-item
|
||||
:href (get item "href") :name (get item "name") :slug (get item "slug")
|
||||
:del-url (get item "del-url") :csrf-hdr (get item "csrf-hdr")
|
||||
:list-id list-id
|
||||
@@ -544,84 +544,84 @@
|
||||
:confirm-text (get item "confirm-text")))
|
||||
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)
|
||||
(~crud-panel
|
||||
(~shared:misc/crud-panel
|
||||
:form (when can-create
|
||||
(~crud-create-form
|
||||
(~shared:misc/crud-create-form
|
||||
:create-url create-url :csrf csrf :errors-id errors-id
|
||||
: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))
|
||||
|
||||
;; 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"
|
||||
(a :href href :class aclass
|
||||
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||
|
||||
;; 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)
|
||||
(<>
|
||||
(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
|
||||
:is-selected (get c "is-selected")))
|
||||
(or calendars (list)))
|
||||
(when container-nav container-nav)
|
||||
(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
|
||||
(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)
|
||||
(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))))
|
||||
|
||||
;; 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)
|
||||
(~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))
|
||||
(or links (list)))))
|
||||
|
||||
;; 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))))
|
||||
(~events-day-entries-nav
|
||||
(~day/entries-nav
|
||||
: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")))
|
||||
entries))))
|
||||
(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
|
||||
(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)
|
||||
(~events-post-search-item
|
||||
(~forms/post-search-item
|
||||
:post-url (get item "post-url") :entry-id (get item "entry-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")))
|
||||
(or items (list)))
|
||||
(cond
|
||||
(has-more (~events-post-search-sentinel :page page :next-url next-url))
|
||||
((not (empty? (or items (list)))) (~events-post-search-end))
|
||||
(has-more (~forms/post-search-sentinel :page page :next-url next-url))
|
||||
((not (empty? (or items (list)))) (~forms/post-search-end))
|
||||
(true ""))))
|
||||
|
||||
;; Entry options from data — state-driven button composition
|
||||
(defcomp ~events-entry-options-from-data (&key entry-id state buttons)
|
||||
(~events-entry-options
|
||||
(defcomp ~page/entry-options-from-data (&key entry-id state buttons)
|
||||
(~admin/entry-options
|
||||
:entry-id entry-id
|
||||
:buttons (<> (map (lambda (b)
|
||||
(~events-entry-option-button
|
||||
(~admin/entry-option-button
|
||||
:url (get b "url") :target (str "#calendar_entry_options_" entry-id)
|
||||
:csrf (get b "csrf") :btn-type (get b "btn-type")
|
||||
:action-btn (get b "action-btn")
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
;; 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"
|
||||
(~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
|
||||
: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")
|
||||
(form :class "mt-4 flex gap-2 items-end" :sx-post create-url
|
||||
@@ -20,15 +20,15 @@
|
||||
:placeholder "e.g. Farm Shop, Bakery"))
|
||||
(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"
|
||||
form
|
||||
(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."))
|
||||
|
||||
(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 "flex items-center justify-between gap-3"
|
||||
(a :class "flex items-baseline gap-3" :href href
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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"
|
||||
(div :class "flex items-start justify-between gap-4"
|
||||
(div :class "flex-1 min-w-0"
|
||||
@@ -12,7 +12,7 @@
|
||||
badge
|
||||
(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
|
||||
(h1 :class "text-2xl font-bold mb-6" "My Tickets")
|
||||
(if has-tickets
|
||||
@@ -22,7 +22,7 @@
|
||||
(p :class "text-lg" "No tickets yet")
|
||||
(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-desc :as string?) (checkin-str :as string?) (qr-script :as string))
|
||||
(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 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 "text-2xl font-bold " text-cls) value)
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(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"
|
||||
(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"
|
||||
(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)
|
||||
(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)
|
||||
@@ -80,7 +80,7 @@
|
||||
(td :class "px-4 py-3" badge)
|
||||
(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
|
||||
(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)
|
||||
@@ -113,11 +113,11 @@
|
||||
(tbody :class "divide-y divide-stone-100" rows))
|
||||
(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"
|
||||
(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)
|
||||
(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)
|
||||
@@ -127,65 +127,65 @@
|
||||
(span :class "text-xs text-blue-600"
|
||||
(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"
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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)))
|
||||
|
||||
(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)))
|
||||
|
||||
(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"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit"
|
||||
: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")))
|
||||
|
||||
(defcomp ~events-lookup-checked-in ()
|
||||
(defcomp ~tickets/lookup-checked-in ()
|
||||
(div :class "text-blue-600 text-center"
|
||||
(i :class "fa fa-check-circle text-3xl" :aria-hidden "true")
|
||||
(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"
|
||||
(i :class "fa fa-times-circle text-3xl" :aria-hidden "true")
|
||||
(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 "flex items-start justify-between gap-4"
|
||||
(div :class "flex-1" info)
|
||||
(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)
|
||||
(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" badge)
|
||||
(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"
|
||||
(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"
|
||||
"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"
|
||||
(table :class "w-full text-sm"
|
||||
(thead :class "bg-stone-50"
|
||||
@@ -195,10 +195,10 @@
|
||||
(th :class "px-4 py-2 text-left font-medium text-stone-600" "Actions")))
|
||||
(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"))
|
||||
|
||||
(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 "flex items-center justify-between"
|
||||
(h3 :class "text-lg font-semibold" (str "Tickets for: " entry-name))
|
||||
@@ -211,72 +211,72 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; My tickets panel from data
|
||||
(defcomp ~events-tickets-panel-from-data (&key (list-container :as string) (tickets :as list?))
|
||||
(~events-tickets-panel
|
||||
(defcomp ~tickets/panel-from-data (&key (list-container :as string) (tickets :as list?))
|
||||
(~tickets/panel
|
||||
:list-container list-container
|
||||
:has-tickets (not (empty? (or tickets (list))))
|
||||
:cards (<> (map (lambda (t)
|
||||
(~events-ticket-card
|
||||
(~tickets/card
|
||||
:href (get t "href") :entry-name (get t "entry-name")
|
||||
:type-name (get t "type-name") :time-str (get t "time-str")
|
||||
: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")))
|
||||
(or tickets (list))))))
|
||||
|
||||
;; 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?)
|
||||
(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
|
||||
: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
|
||||
:time-date time-date :time-range time-range
|
||||
:cal-name cal-name :type-desc type-desc
|
||||
:checkin-str checkin-str :qr-script qr-script))
|
||||
|
||||
;; 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)
|
||||
(checked-in-time :as string?))
|
||||
(~events-ticket-admin-row
|
||||
(~tickets/admin-row
|
||||
:code code :code-short code-short
|
||||
: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
|
||||
:badge (~ticket-state-badge :state state)
|
||||
:badge (~entries/ticket-state-badge :state state)
|
||||
:action (cond
|
||||
((or (= state "confirmed") (= state "reserved"))
|
||||
(~events-ticket-admin-checkin-form
|
||||
(~tickets/admin-checkin-form
|
||||
:checkin-url checkin-url :code code :csrf csrf))
|
||||
((= 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))))
|
||||
|
||||
;; 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?))
|
||||
(~events-ticket-admin-panel
|
||||
(~tickets/admin-panel
|
||||
:list-container list-container
|
||||
: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"
|
||||
: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"
|
||||
: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"
|
||||
: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"
|
||||
:value (str (or reserved 0)) :label "Reserved"))
|
||||
:lookup-url lookup-url
|
||||
:has-tickets (not (empty? (or tickets (list))))
|
||||
: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")
|
||||
:entry-name (get t "entry-name") :date-str (get t "date-str")
|
||||
:type-name (get t "type-name") :state (get t "state")
|
||||
@@ -285,45 +285,45 @@
|
||||
(or tickets (list))))))
|
||||
|
||||
;; 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))
|
||||
(~events-entry-tickets-admin-panel
|
||||
(defcomp ~tickets/entry-tickets-admin-from-data (&key (entry-name :as string) (count-label :as string) (tickets :as list?) (csrf :as string))
|
||||
(~tickets/entry-tickets-admin-panel
|
||||
:entry-name entry-name :count-label count-label
|
||||
:body (if (empty? (or tickets (list)))
|
||||
(~events-entry-tickets-admin-empty)
|
||||
(~events-entry-tickets-admin-table
|
||||
(~tickets/entry-tickets-admin-empty)
|
||||
(~tickets/entry-tickets-admin-table
|
||||
:rows (<> (map (lambda (t)
|
||||
(~events-entry-tickets-admin-row
|
||||
(~tickets/entry-tickets-admin-row
|
||||
:code (get t "code") :code-short (get t "code-short")
|
||||
: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
|
||||
((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))
|
||||
((= (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))))
|
||||
(or tickets (list))))))))
|
||||
|
||||
;; 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))
|
||||
(~events-checkin-success-row
|
||||
(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))
|
||||
(~tickets/checkin-success-row
|
||||
:code code :code-short code-short
|
||||
: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
|
||||
:badge (~ticket-state-badge :state "checked_in")
|
||||
:badge (~entries/ticket-state-badge :state "checked_in")
|
||||
:time-str time-str))
|
||||
|
||||
;; 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))
|
||||
(~events-ticket-types-table
|
||||
(~page/ticket-types-table
|
||||
:list-container list-container
|
||||
:rows (if (empty? (or ticket-types (list)))
|
||||
(~events-ticket-types-empty-row)
|
||||
(~page/ticket-types-empty-row)
|
||||
(<> (map (lambda (tt)
|
||||
(~events-ticket-types-row
|
||||
(~page/ticket-types-row
|
||||
:tr-cls tr-cls :tt-href (get tt "tt-href")
|
||||
:pill-cls pill-cls :hx-select hx-select
|
||||
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
|
||||
@@ -333,23 +333,23 @@
|
||||
:action-btn action-btn :add-url add-url))
|
||||
|
||||
;; 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?)
|
||||
(checkin-url :as string) (csrf :as string))
|
||||
(~events-lookup-card
|
||||
(~tickets/lookup-card
|
||||
:info (<>
|
||||
(~events-lookup-info :entry-name entry-name)
|
||||
(when type-name (~events-lookup-type :type-name type-name))
|
||||
(when date-str (~events-lookup-date :date-str date-str))
|
||||
(when cal-name (~events-lookup-cal :cal-name cal-name))
|
||||
(~events-lookup-status
|
||||
:badge (~ticket-state-badge :state state) :code code)
|
||||
(~tickets/lookup-info :entry-name entry-name)
|
||||
(when type-name (~tickets/lookup-type :type-name type-name))
|
||||
(when date-str (~tickets/lookup-date :date-str date-str))
|
||||
(when cal-name (~tickets/lookup-cal :cal-name cal-name))
|
||||
(~tickets/lookup-status
|
||||
:badge (~entries/ticket-state-badge :state state) :code code)
|
||||
(when checked-in-str
|
||||
(~events-lookup-checkin-time :date-str checked-in-str)))
|
||||
(~tickets/lookup-checkin-time :date-str checked-in-str)))
|
||||
:code code
|
||||
:action (cond
|
||||
((or (= state "confirmed") (= state "reserved"))
|
||||
(~events-lookup-checkin-btn :checkin-url checkin-url :code code :csrf csrf))
|
||||
((= state "checked_in") (~events-lookup-checked-in))
|
||||
((= state "cancelled") (~events-lookup-cancelled))
|
||||
(~tickets/lookup-checkin-btn :checkin-url checkin-url :code code :csrf csrf))
|
||||
((= state "checked_in") (~tickets/lookup-checked-in))
|
||||
((= state "cancelled") (~tickets/lookup-cancelled))
|
||||
(true nil))))
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
:auth :admin
|
||||
:layout :events-calendar-admin
|
||||
:data (calendar-admin-data calendar-slug)
|
||||
:content (~events-calendar-admin-panel
|
||||
:description-content (~events-calendar-description-display
|
||||
:content (~admin/calendar-admin-panel
|
||||
:description-content (~calendar/description-display
|
||||
:description cal-description :edit-url desc-edit-url)
|
||||
:csrf csrf :description cal-description))
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
:auth :admin
|
||||
:layout :events-day-admin
|
||||
:data (day-admin-data calendar-slug year month day)
|
||||
:content (~events-day-admin-panel))
|
||||
:content (~day/admin-panel))
|
||||
|
||||
;; Slots listing
|
||||
(defpage slots-listing
|
||||
@@ -26,25 +26,25 @@
|
||||
:auth :public
|
||||
:layout :events-slots
|
||||
:data (slots-data calendar-slug)
|
||||
:content (~events-slots-table
|
||||
:content (~page/slots-table
|
||||
:list-container list-container
|
||||
:rows (if has-slots
|
||||
(<> (map (fn (s)
|
||||
(~events-slots-row
|
||||
(~page/slots-row
|
||||
:tr-cls tr-cls :slot-href (get s "slot-href")
|
||||
:pill-cls pill-cls :hx-select hx-select
|
||||
:slot-name (get s "name") :description (get s "description")
|
||||
:flexible (get s "flexible")
|
||||
:days (if (get s "has-days")
|
||||
(~events-slot-days-pills :days-inner
|
||||
(<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list"))))
|
||||
(~events-slot-no-days))
|
||||
(~page/slot-days-pills :days-inner
|
||||
(<> (map (fn (d) (~page/slot-day-pill :day d)) (get s "day-list"))))
|
||||
(~page/slot-no-days))
|
||||
:time-str (get s "time-str")
|
||||
:cost-str (get s "cost-str") :action-btn action-btn
|
||||
:del-url (get s "del-url")
|
||||
:csrf-hdr csrf-hdr))
|
||||
slots-list))
|
||||
(~events-slots-empty-row))
|
||||
(~page/slots-empty-row))
|
||||
:pre-action pre-action :add-url add-url))
|
||||
|
||||
;; Slot detail
|
||||
@@ -53,13 +53,13 @@
|
||||
:auth :admin
|
||||
:layout :events-slot
|
||||
:data (slot-data calendar-slug slot-id)
|
||||
:content (~events-slot-panel
|
||||
:content (~page/slot-panel
|
||||
:slot-id slot-id-str
|
||||
:list-container list-container
|
||||
:days (if has-days
|
||||
(~events-slot-days-pills :days-inner
|
||||
(<> (map (fn (d) (~events-slot-day-pill :day d)) day-list)))
|
||||
(~events-slot-no-days))
|
||||
(~page/slot-days-pills :days-inner
|
||||
(<> (map (fn (d) (~page/slot-day-pill :day d)) day-list)))
|
||||
(~page/slot-no-days))
|
||||
:flexible flexible
|
||||
:time-str time-str :cost-str cost-str
|
||||
:pre-action pre-action :edit-url edit-url))
|
||||
@@ -70,29 +70,29 @@
|
||||
:auth :admin
|
||||
:layout :events-entry
|
||||
:data (entry-data calendar-slug entry-id)
|
||||
:content (~events-entry-panel
|
||||
:content (~admin/entry-panel
|
||||
:entry-id entry-id-str :list-container list-container
|
||||
:name (~events-entry-field :label "Name"
|
||||
:content (~events-entry-name-field :name entry-name))
|
||||
:slot (~events-entry-field :label "Slot"
|
||||
:name (~admin/entry-field :label "Name"
|
||||
:content (~admin/entry-name-field :name entry-name))
|
||||
:slot (~admin/entry-field :label "Slot"
|
||||
:content (if has-slot
|
||||
(~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label)
|
||||
(~events-entry-slot-none)))
|
||||
:time (~events-entry-field :label "Time Period"
|
||||
:content (~events-entry-time-field :time-str time-str))
|
||||
:state (~events-entry-field :label "State"
|
||||
:content (~events-entry-state-field :entry-id entry-id-str
|
||||
:badge (~badge :cls state-badge-cls :label state-badge-label)))
|
||||
:cost (~events-entry-field :label "Cost"
|
||||
:content (~events-entry-cost-field :cost cost-str))
|
||||
:tickets (~events-entry-field :label "Tickets"
|
||||
:content (~events-entry-tickets-field :entry-id entry-id-str
|
||||
(~admin/entry-slot-assigned :slot-name slot-name :flex-label flex-label)
|
||||
(~admin/entry-slot-none)))
|
||||
:time (~admin/entry-field :label "Time Period"
|
||||
:content (~admin/entry-time-field :time-str time-str))
|
||||
:state (~admin/entry-field :label "State"
|
||||
:content (~admin/entry-state-field :entry-id entry-id-str
|
||||
:badge (~shared:misc/badge :cls state-badge-cls :label state-badge-label)))
|
||||
:cost (~admin/entry-field :label "Cost"
|
||||
:content (~admin/entry-cost-field :cost cost-str))
|
||||
:tickets (~admin/entry-field :label "Tickets"
|
||||
:content (~admin/entry-tickets-field :entry-id entry-id-str
|
||||
:tickets-config tickets-config))
|
||||
:buy buy-form
|
||||
:date (~events-entry-field :label "Date"
|
||||
:content (~events-entry-date-field :date-str date-str))
|
||||
:posts (~events-entry-field :label "Associated Posts"
|
||||
:content (~events-entry-posts-field :entry-id entry-id-str
|
||||
:date (~admin/entry-field :label "Date"
|
||||
:content (~admin/entry-date-field :date-str date-str))
|
||||
:posts (~admin/entry-field :label "Associated Posts"
|
||||
:content (~admin/entry-posts-field :entry-id entry-id-str
|
||||
:posts-panel posts-panel))
|
||||
:options options-html
|
||||
:pre-action pre-action :edit-url edit-url)
|
||||
@@ -104,9 +104,9 @@
|
||||
:auth :admin
|
||||
:layout :events-entry-admin
|
||||
: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)
|
||||
:menu (~events-admin-placeholder-nav))
|
||||
:menu (~forms/admin-placeholder-nav))
|
||||
|
||||
;; Ticket types listing
|
||||
(defpage ticket-types-listing
|
||||
@@ -114,11 +114,11 @@
|
||||
:auth :public
|
||||
:layout :events-ticket-types
|
||||
: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
|
||||
:rows (if has-types
|
||||
(<> (map (fn (tt)
|
||||
(~events-ticket-types-row
|
||||
(~page/ticket-types-row
|
||||
:tr-cls tr-cls :tt-href (get tt "tt-href")
|
||||
:pill-cls pill-cls :hx-select hx-select
|
||||
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
|
||||
@@ -126,9 +126,9 @@
|
||||
:del-url (get tt "del-url")
|
||||
:csrf-hdr csrf-hdr))
|
||||
types-list))
|
||||
(~events-ticket-types-empty-row))
|
||||
(~page/ticket-types-empty-row))
|
||||
:action-btn action-btn :add-url add-url)
|
||||
:menu (~events-admin-placeholder-nav))
|
||||
:menu (~forms/admin-placeholder-nav))
|
||||
|
||||
;; Ticket type detail
|
||||
(defpage ticket-type-detail
|
||||
@@ -136,13 +136,13 @@
|
||||
:auth :admin
|
||||
:layout :events-ticket-type
|
||||
: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
|
||||
:c1 (~events-ticket-type-col :label "Name" :value tt-name)
|
||||
:c2 (~events-ticket-type-col :label "Cost" :value cost-str)
|
||||
:c3 (~events-ticket-type-col :label "Count" :value count-str)
|
||||
:c1 (~page/ticket-type-col :label "Name" :value tt-name)
|
||||
:c2 (~page/ticket-type-col :label "Cost" :value cost-str)
|
||||
:c3 (~page/ticket-type-col :label "Count" :value count-str)
|
||||
:pre-action pre-action :edit-url edit-url)
|
||||
:menu (~events-admin-placeholder-nav))
|
||||
:menu (~forms/admin-placeholder-nav))
|
||||
|
||||
;; My tickets
|
||||
(defpage my-tickets
|
||||
@@ -150,16 +150,16 @@
|
||||
:auth :public
|
||||
:layout :root
|
||||
:data (tickets-data)
|
||||
:content (~events-tickets-panel
|
||||
:content (~tickets/panel
|
||||
:list-container list-container
|
||||
:has-tickets has-tickets
|
||||
:cards (when has-tickets
|
||||
(<> (map (fn (t)
|
||||
(~events-ticket-card
|
||||
(~tickets/card
|
||||
:href (get t "href") :entry-name (get t "entry-name")
|
||||
:type-name (get t "type-name") :time-str (get t "time-str")
|
||||
: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")))
|
||||
tickets-list)))))
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
:auth :public
|
||||
:layout :root
|
||||
:data (ticket-detail-data code)
|
||||
:content (~events-ticket-detail
|
||||
:content (~tickets/detail
|
||||
:list-container list-container :back-href back-href
|
||||
: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)
|
||||
@@ -185,10 +185,10 @@
|
||||
:auth :admin
|
||||
:layout :root
|
||||
:data (ticket-admin-data)
|
||||
:content (~events-ticket-admin-panel
|
||||
:content (~tickets/admin-panel
|
||||
:list-container list-container
|
||||
:stats (<> (map (fn (s)
|
||||
(~events-ticket-admin-stat
|
||||
(~tickets/admin-stat
|
||||
:border (get s "border") :bg (get s "bg")
|
||||
:text-cls (get s "text-cls") :label-cls (get s "label-cls")
|
||||
:value (get s "value") :label (get s "label")))
|
||||
@@ -196,18 +196,18 @@
|
||||
:lookup-url lookup-url :has-tickets has-tickets
|
||||
:rows (when has-tickets
|
||||
(<> (map (fn (t)
|
||||
(~events-ticket-admin-row
|
||||
(~tickets/admin-row
|
||||
:code (get t "code") :code-short (get t "code-short")
|
||||
:entry-name (get t "entry-name")
|
||||
: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")
|
||||
: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")
|
||||
(~events-ticket-admin-checkin-form
|
||||
(~tickets/admin-checkin-form
|
||||
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf)
|
||||
(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)))))
|
||||
|
||||
;; Markets
|
||||
@@ -216,20 +216,20 @@
|
||||
:auth :public
|
||||
:layout :events-markets
|
||||
:data (markets-data)
|
||||
:content (~crud-panel
|
||||
:content (~shared:misc/crud-panel
|
||||
:list-id "markets-list"
|
||||
: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"
|
||||
:placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market"))
|
||||
:list (if markets-list
|
||||
(<> (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")
|
||||
:csrf-hdr (get m "csrf-hdr")
|
||||
:list-id "markets-list"
|
||||
:confirm-title "Delete market?"
|
||||
:confirm-text "Products will be hidden (soft delete)"))
|
||||
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"))))
|
||||
|
||||
@@ -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,
|
||||
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)
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets
|
||||
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))
|
||||
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)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
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)
|
||||
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)
|
||||
@@ -183,7 +183,7 @@ async def render_calendars_oob(ctx: dict) -> str:
|
||||
async def render_calendar_page(ctx: dict) -> str:
|
||||
"""Full page: calendar month view."""
|
||||
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)
|
||||
hdr += await header_child_sx(child)
|
||||
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:
|
||||
"""Full page: day detail."""
|
||||
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)
|
||||
+ _calendar_header_sx(ctx) + _day_header_sx(ctx))
|
||||
hdr += await header_child_sx(child)
|
||||
|
||||
@@ -117,7 +117,7 @@ def _cart_icon_oob(count: int) -> str:
|
||||
|
||||
|
||||
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
|
||||
|
||||
blog_url_fn = getattr(g, "blog_url", None)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
;; Auth components (choose username — federation-specific)
|
||||
;; 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"
|
||||
(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: "
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
(let ((actor (service "federation" "get-actor-by-username" :username u)))
|
||||
(<> (str "<!-- fragment:" u " -->")
|
||||
(when (not (nil? actor))
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "federation"
|
||||
(str "/users/" (get actor "preferred_username")))
|
||||
:title (or (get actor "display_name")
|
||||
@@ -28,7 +28,7 @@
|
||||
(let ((actor (service "federation" "get-actor-by-username"
|
||||
:username lookup)))
|
||||
(when (not (nil? actor))
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:link (app-url "federation"
|
||||
(str "/users/" (get actor "preferred_username")))
|
||||
:title (or (get actor "display_name")
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
;; Registered via register_sx_layout("social", ...) in __init__.py.
|
||||
|
||||
;; Full page: root header + social header in header-child
|
||||
(defcomp ~social-layout-full ()
|
||||
(defcomp ~layouts/social-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
:inner (~federation-social-header
|
||||
:nav (~federation-social-nav :actor (federation-actor-ctx))))))
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (~social/header
|
||||
:nav (~social/nav :actor (federation-actor-ctx))))))
|
||||
|
||||
;; OOB (HTMX): social header oob + root header oob
|
||||
(defcomp ~social-layout-oob ()
|
||||
(<> (~oob-header-sx
|
||||
(defcomp ~layouts/social-layout-oob ()
|
||||
(<> (~shared:layout/oob-header-sx
|
||||
:parent-id "root-header-child"
|
||||
:row (~federation-social-header
|
||||
:nav (~federation-social-nav :actor (federation-actor-ctx))))
|
||||
:row (~social/header
|
||||
:nav (~social/nav :actor (federation-actor-ctx))))
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
;; 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))
|
||||
|
||||
(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 "flex items-start gap-3"
|
||||
avatar
|
||||
@@ -15,14 +15,14 @@
|
||||
preview
|
||||
(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))
|
||||
|
||||
(defcomp ~federation-notifications-page (&key notifs)
|
||||
(defcomp ~notifications/page (&key notifs)
|
||||
(h1 :class "text-2xl font-bold mb-6" "Notifications") notifs)
|
||||
|
||||
;; 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") "?"))
|
||||
(from-username (or (get notif "from_actor_username") ""))
|
||||
(from-domain (or (get notif "from_actor_domain") ""))
|
||||
@@ -44,9 +44,9 @@
|
||||
((= ntype "mention") "mentioned you")
|
||||
((= ntype "reply") "replied to your post")
|
||||
(true ""))))
|
||||
(~federation-notification-card
|
||||
(~notifications/card
|
||||
:cls (str "bg-white rounded-lg shadow-sm border border-stone-200 p-4" border)
|
||||
:avatar (~avatar
|
||||
:avatar (~shared:misc/avatar
|
||||
:src from-icon
|
||||
: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")
|
||||
@@ -55,15 +55,15 @@
|
||||
:from-username (escape from-username)
|
||||
:from-domain (if from-domain (str "@" (escape from-domain)) "")
|
||||
:action-text action-text
|
||||
:preview (when preview (~federation-notification-preview :preview (escape preview)))
|
||||
:preview (when preview (~notifications/preview :preview (escape preview)))
|
||||
:time created)))
|
||||
|
||||
;; Assembled notifications content — replaces Python _notifications_content_sx
|
||||
(defcomp ~federation-notifications-content (&key (notifications :as list))
|
||||
(~federation-notifications-page
|
||||
(defcomp ~notifications/content (&key (notifications :as list))
|
||||
(~notifications/page
|
||||
:notifs (if (empty? notifications)
|
||||
(~empty-state :message "No notifications yet." :cls "text-stone-500")
|
||||
(~federation-notifications-list
|
||||
(~shared:misc/empty-state :message "No notifications yet." :cls "text-stone-500")
|
||||
(~notifications/list
|
||||
:items (map (lambda (n)
|
||||
(~federation-notification-from-data :notif n))
|
||||
(~notifications/from-data :notif n))
|
||||
notifications)))))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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 "flex items-center gap-4"
|
||||
avatar
|
||||
@@ -10,39 +10,39 @@
|
||||
summary)
|
||||
follow)))
|
||||
|
||||
(defcomp ~federation-actor-timeline-layout (&key header timeline)
|
||||
(defcomp ~profile/actor-timeline-layout (&key header timeline)
|
||||
header
|
||||
(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"
|
||||
(form :method "post" :action action
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "actor_url" :value actor-url)
|
||||
(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)))
|
||||
|
||||
;; 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))
|
||||
|
||||
(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 "flex justify-between items-start"
|
||||
(span :class "font-medium" activity-type)
|
||||
(span :class "text-sm text-stone-400" published))
|
||||
obj-type))
|
||||
|
||||
(defcomp ~federation-activities-list (&key (items :as list))
|
||||
(defcomp ~profile/activities-list (&key (items :as list))
|
||||
(div :class "space-y-4" items))
|
||||
|
||||
(defcomp ~federation-activities-empty ()
|
||||
(defcomp ~profile/activities-empty ()
|
||||
(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 "bg-white rounded-lg shadow p-6 mb-6"
|
||||
(h1 :class "text-2xl font-bold" display-name)
|
||||
@@ -51,11 +51,11 @@
|
||||
(h2 :class "text-xl font-bold mb-4" activities-heading)
|
||||
activities))
|
||||
|
||||
(defcomp ~federation-profile-summary-text (&key (text :as string))
|
||||
(defcomp ~profile/summary-text (&key (text :as string))
|
||||
(p :class "mt-2" text))
|
||||
|
||||
;; 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") ""))
|
||||
(icon-url (get remote-actor "icon_url"))
|
||||
(summary (get remote-actor "summary"))
|
||||
@@ -63,9 +63,9 @@
|
||||
(csrf (csrf-token))
|
||||
(initial (if (and (not icon-url) display-name)
|
||||
(upper (slice display-name 0 1)) "?")))
|
||||
(~federation-actor-timeline-layout
|
||||
:header (~federation-actor-profile-header
|
||||
:avatar (~avatar
|
||||
(~profile/actor-timeline-layout
|
||||
:header (~profile/actor-profile-header
|
||||
:avatar (~shared:misc/avatar
|
||||
:src icon-url
|
||||
: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")
|
||||
@@ -73,18 +73,18 @@
|
||||
:display-name (escape display-name)
|
||||
:username (escape (or (get remote-actor "preferred_username") ""))
|
||||
: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
|
||||
(if is-following
|
||||
(~federation-follow-form
|
||||
(~profile/follow-form
|
||||
:action (url-for "social.unfollow") :csrf csrf :actor-url actor-url
|
||||
:label "Unfollow"
|
||||
: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
|
||||
:label "Follow"
|
||||
: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
|
||||
:next-url (when (not (empty? items))
|
||||
(url-for "social.actor_timeline_page"
|
||||
@@ -92,14 +92,14 @@
|
||||
:before (get (last items) "before_cursor")))))))
|
||||
|
||||
;; 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)))
|
||||
(~federation-activities-empty)
|
||||
(~federation-activities-list
|
||||
(~profile/activities-empty)
|
||||
(~profile/activities-list
|
||||
:items (<> (map (lambda (a)
|
||||
(~federation-activity-card
|
||||
(~profile/activity-card
|
||||
:activity-type (get a "activity_type")
|
||||
:published (get a "published")
|
||||
: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)))))
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
;; Search and actor card components
|
||||
|
||||
;; Aliases — delegate to shared ~avatar
|
||||
(defcomp ~federation-actor-avatar-img (&key (src :as string) (cls :as string))
|
||||
(~avatar :src src :cls cls))
|
||||
;; Aliases — delegate to shared ~shared:misc/avatar
|
||||
(defcomp ~search/actor-avatar-img (&key (src :as string) (cls :as string))
|
||||
(~shared:misc/avatar :src src :cls cls))
|
||||
|
||||
(defcomp ~federation-actor-avatar-placeholder (&key (cls :as string) (initial :as string))
|
||||
(~avatar :cls cls :initial initial))
|
||||
(defcomp ~search/actor-avatar-placeholder (&key (cls :as string) (initial :as string))
|
||||
(~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))
|
||||
|
||||
(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"
|
||||
: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)))
|
||||
|
||||
(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"
|
||||
(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 "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"))))
|
||||
|
||||
(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"
|
||||
(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 "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))))
|
||||
|
||||
(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
|
||||
avatar
|
||||
(div :class "flex-1 min-w-0"
|
||||
@@ -41,7 +41,7 @@
|
||||
button))
|
||||
|
||||
;; 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"))
|
||||
(display-name (get d "display_name"))
|
||||
(username (get d "username"))
|
||||
@@ -49,42 +49,42 @@
|
||||
(actor-url (get d "actor_url"))
|
||||
(safe-id (get d "safe_id"))
|
||||
(initial (or (get d "initial") "?"))
|
||||
(avatar (~avatar
|
||||
(avatar (~shared:misc/avatar
|
||||
:src icon-url
|
||||
: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")
|
||||
:initial (when (not icon-url) initial)))
|
||||
(name-sx (if (get d "external_link")
|
||||
(~federation-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-external :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")
|
||||
(~federation-actor-summary :summary (get d "summary"))))
|
||||
(~search/actor-summary :summary (get d "summary"))))
|
||||
(is-followed (get d "is_followed"))
|
||||
(button (when has-actor
|
||||
(if (or (= list-type "following") is-followed)
|
||||
(~federation-unfollow-button :action unfollow-url :csrf csrf :actor-url actor-url)
|
||||
(~federation-follow-button :action follow-url :csrf csrf :actor-url actor-url
|
||||
(~search/unfollow-button :action unfollow-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"))))))
|
||||
(~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"
|
||||
:id (str "actor-" safe-id)
|
||||
:avatar avatar :name name-sx :username username :domain domain
|
||||
:summary summary-sx :button button)))
|
||||
|
||||
;; 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))
|
||||
(<>
|
||||
(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))
|
||||
(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))
|
||||
|
||||
(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")
|
||||
(form :method "get" :action search-url :class "mb-6"
|
||||
:sx-get search-page-url :sx-target "#search-results" :sx-push-url search-url
|
||||
@@ -97,7 +97,7 @@
|
||||
(div :id "search-results" results))
|
||||
|
||||
;; 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 " "
|
||||
(span :class "text-stone-400 font-normal" count-str))
|
||||
(div :id "actor-list" items))
|
||||
@@ -106,7 +106,7 @@
|
||||
;; 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") ""))
|
||||
(username (or (get a "preferred_username") ""))
|
||||
(domain (or (get a "domain") ""))
|
||||
@@ -119,81 +119,81 @@
|
||||
(upper (slice (or display-name username) 0 1)) "?"))
|
||||
(csrf (csrf-token))
|
||||
(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"
|
||||
:id (str "actor-" safe-id)
|
||||
:avatar (~avatar
|
||||
:avatar (~shared:misc/avatar
|
||||
:src icon-url
|
||||
: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")
|
||||
:initial (when (not icon-url) initial))
|
||||
: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)
|
||||
:name (escape display-name))
|
||||
(~federation-actor-name-link-external
|
||||
(~search/actor-name-link-external
|
||||
:href (str "https://" domain "/@" username)
|
||||
:name (escape display-name)))
|
||||
:username (escape username)
|
||||
:domain (escape domain)
|
||||
:summary (when summary (~federation-actor-summary :summary summary))
|
||||
:summary (when summary (~search/actor-summary :summary summary))
|
||||
:button (when actor
|
||||
(if (or (= list-type "following") is-followed)
|
||||
(~federation-unfollow-button
|
||||
(~search/unfollow-button
|
||||
: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
|
||||
:label (if (= list-type "followers") "Follow Back" "Follow")))))))
|
||||
|
||||
;; 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)
|
||||
(~federation-search-page
|
||||
(defcomp ~search/content (&key (query :as string?) (actors :as list) (total :as number) (followed-urls :as list) actor)
|
||||
(~search/page
|
||||
:search-url (url-for "social.defpage_search")
|
||||
:search-page-url (url-for "social.search_page")
|
||||
:query (escape (or query ""))
|
||||
:info (cond
|
||||
((and query (> total 0))
|
||||
(~federation-search-info
|
||||
(~search/info
|
||||
:cls "text-sm text-stone-500 mb-4"
|
||||
:text (str total " result" (pluralize total) " for " (escape query))))
|
||||
(query
|
||||
(~federation-search-info
|
||||
(~search/info
|
||||
:cls "text-stone-500 mb-4"
|
||||
:text (str "No results found for " (escape query))))
|
||||
(true nil))
|
||||
:results (when (not (empty? actors))
|
||||
(<>
|
||||
(map (lambda (a)
|
||||
(~federation-actor-card-from-data
|
||||
(~search/actor-card-from-data
|
||||
:a a :actor actor :followed-urls followed-urls :list-type "search"))
|
||||
actors)
|
||||
(when (>= (len actors) 20)
|
||||
(~federation-scroll-sentinel
|
||||
(~social/scroll-sentinel
|
||||
:url (url-for "social.search_page" :q query :page 2)))))))
|
||||
|
||||
;; Assembled following/followers content — replaces Python _following_content_sx etc.
|
||||
(defcomp ~federation-following-content (&key (actors :as list) (total :as number) actor)
|
||||
(~federation-actor-list-page
|
||||
(defcomp ~search/following-content (&key (actors :as list) (total :as number) actor)
|
||||
(~search/actor-list-page
|
||||
:title "Following" :count-str (str "(" total ")")
|
||||
:items (when (not (empty? actors))
|
||||
(<>
|
||||
(map (lambda (a)
|
||||
(~federation-actor-card-from-data
|
||||
(~search/actor-card-from-data
|
||||
:a a :actor actor :followed-urls (list) :list-type "following"))
|
||||
actors)
|
||||
(when (>= (len actors) 20)
|
||||
(~federation-scroll-sentinel
|
||||
(~social/scroll-sentinel
|
||||
: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)
|
||||
(~federation-actor-list-page
|
||||
(defcomp ~search/followers-content (&key (actors :as list) (total :as number) (followed-urls :as list) actor)
|
||||
(~search/actor-list-page
|
||||
:title "Followers" :count-str (str "(" total ")")
|
||||
:items (when (not (empty? actors))
|
||||
(<>
|
||||
(map (lambda (a)
|
||||
(~federation-actor-card-from-data
|
||||
(~search/actor-card-from-data
|
||||
:a a :actor actor :followed-urls followed-urls :list-type "followers"))
|
||||
actors)
|
||||
(when (>= (len actors) 20)
|
||||
(~federation-scroll-sentinel
|
||||
(~social/scroll-sentinel
|
||||
:url (url-for "social.followers_list_page" :page 2)))))))
|
||||
|
||||
@@ -2,46 +2,46 @@
|
||||
|
||||
;; --- 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"
|
||||
(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"
|
||||
(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")))
|
||||
|
||||
(defcomp ~federation-nav-bar (&key items)
|
||||
(defcomp ~social/nav-bar (&key 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 :class "w-full flex flex-row items-center gap-2 flex-wrap" nav)))
|
||||
|
||||
;; --- 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))
|
||||
|
||||
;; Aliases — delegate to shared ~avatar
|
||||
(defcomp ~federation-avatar-img (&key (src :as string) (cls :as string))
|
||||
(~avatar :src src :cls cls))
|
||||
;; Aliases — delegate to shared ~shared:misc/avatar
|
||||
(defcomp ~social/avatar-img (&key (src :as string) (cls :as string))
|
||||
(~shared:misc/avatar :src src :cls cls))
|
||||
|
||||
(defcomp ~federation-avatar-placeholder (&key (cls :as string) (initial :as string))
|
||||
(~avatar :cls cls :initial initial))
|
||||
(defcomp ~social/avatar-placeholder (&key (cls :as string) (initial :as string))
|
||||
(~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
|
||||
(details :class "mt-2"
|
||||
(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))))
|
||||
|
||||
(defcomp ~federation-original-link (&key (url :as string))
|
||||
(defcomp ~social/original-link (&key (url :as string))
|
||||
(a :href url :target "_blank" :rel "noopener"
|
||||
: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"
|
||||
boost
|
||||
(div :class "flex items-start gap-3"
|
||||
@@ -55,36 +55,36 @@
|
||||
|
||||
;; --- 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"))
|
||||
|
||||
(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"
|
||||
(input :type "hidden" :name "object_id" :value oid)
|
||||
(input :type "hidden" :name "author_inbox" :value ainbox)
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(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"
|
||||
(input :type "hidden" :name "object_id" :value oid)
|
||||
(input :type "hidden" :name "author_inbox" :value ainbox)
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(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"
|
||||
like boost reply))
|
||||
|
||||
;; --- 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"))
|
||||
|
||||
(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"))
|
||||
|
||||
(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"
|
||||
(h1 :class "text-2xl font-bold" label " Timeline")
|
||||
compose)
|
||||
@@ -92,24 +92,24 @@
|
||||
|
||||
;; --- 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)
|
||||
(boost-url :as string) (unboost-url :as string))
|
||||
(let* ((boosted-by (get d "boosted_by"))
|
||||
(actor-icon (get d "actor_icon"))
|
||||
(actor-name (get d "actor_name"))
|
||||
(initial (or (get d "initial") "?"))
|
||||
(avatar (~avatar
|
||||
(avatar (~shared:misc/avatar
|
||||
:src actor-icon
|
||||
: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")
|
||||
: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")
|
||||
(~federation-content :content (get d "content") :summary (get d "summary"))
|
||||
(~federation-content :content (get d "content"))))
|
||||
(~social/content :content (get d "content") :summary (get d "summary"))
|
||||
(~social/content :content (get d "content"))))
|
||||
(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"))
|
||||
(interactions (when has-actor
|
||||
(let* ((oid (get d "object_id"))
|
||||
@@ -123,16 +123,16 @@
|
||||
(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")))
|
||||
(reply-url (get d "reply_url"))
|
||||
(reply (when reply-url (~federation-reply-link :url reply-url)))
|
||||
(like-form (~federation-like-form
|
||||
(reply (when reply-url (~social/reply-link :url reply-url)))
|
||||
(like-form (~social/like-form
|
||||
:action l-action :target target :oid oid :ainbox ainbox
|
||||
: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
|
||||
:csrf csrf :cls b-cls :count (get d "boost_count"))))
|
||||
(div :id (str "interactions-" safe-id)
|
||||
(~federation-interaction-buttons :like like-form :boost boost-form :reply reply))))))
|
||||
(~federation-post-card
|
||||
(~social/interaction-buttons :like like-form :boost boost-form :reply reply))))))
|
||||
(~social/post-card
|
||||
:boost boost :avatar avatar
|
||||
:actor-name actor-name :actor-username (get d "actor_username")
|
||||
:domain (get d "domain") :time (get d "time")
|
||||
@@ -140,22 +140,22 @@
|
||||
:interactions interactions)))
|
||||
|
||||
;; 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))
|
||||
(<>
|
||||
(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))
|
||||
(or items (list)))
|
||||
(when next-url (~federation-scroll-sentinel :url next-url))))
|
||||
(when next-url (~social/scroll-sentinel :url next-url))))
|
||||
|
||||
;; --- 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)
|
||||
(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")
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
@@ -174,9 +174,9 @@
|
||||
;; Assembled social nav — replaces Python _social_nav_sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~federation-social-nav (&key actor)
|
||||
(defcomp ~social/nav (&key 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))
|
||||
(links (list
|
||||
(dict :endpoint "social.defpage_home_timeline" :label "Timeline")
|
||||
@@ -185,7 +185,7 @@
|
||||
(dict :endpoint "social.defpage_following_list" :label "Following")
|
||||
(dict :endpoint "social.defpage_followers_list" :label "Followers")
|
||||
(dict :endpoint "social.defpage_search" :label "Search"))))
|
||||
(~federation-nav-bar
|
||||
(~social/nav-bar
|
||||
:items (<>
|
||||
(map (lambda (lnk)
|
||||
(let* ((href (url-for (get lnk "endpoint")))
|
||||
@@ -196,7 +196,7 @@
|
||||
links)
|
||||
(let* ((notif-url (url-for "social.defpage_notifications"))
|
||||
(notif-bold (if (= rp notif-url) " font-bold" "")))
|
||||
(~federation-nav-notification-link
|
||||
(~social/nav-notification-link
|
||||
:href notif-url
|
||||
:cls (str "px-2 py-1 rounded hover:bg-stone-200 relative" notif-bold)
|
||||
:count-url (url-for "social.notification_count")))
|
||||
@@ -208,7 +208,7 @@
|
||||
;; 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"))
|
||||
(actor-icon (get item "actor_icon"))
|
||||
(actor-name (or (get item "actor_name") "?"))
|
||||
@@ -223,9 +223,9 @@
|
||||
(safe-id (replace (replace oid "/" "_") ":" "_"))
|
||||
(initial (if (and (not actor-icon) actor-name)
|
||||
(upper (slice actor-name 0 1)) "?")))
|
||||
(~federation-post-card
|
||||
:boost (when boosted-by (~federation-boost-label :name (escape boosted-by)))
|
||||
:avatar (~avatar
|
||||
(~social/post-card
|
||||
:boost (when boosted-by (~social/boost-label :name (escape boosted-by)))
|
||||
:avatar (~shared:misc/avatar
|
||||
:src actor-icon
|
||||
: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")
|
||||
@@ -235,10 +235,10 @@
|
||||
:domain (if actor-domain (str "@" (escape actor-domain)) "")
|
||||
:time published
|
||||
:content (if summary
|
||||
(~federation-content :content content :summary (escape summary))
|
||||
(~federation-content :content content))
|
||||
(~social/content :content content :summary (escape summary))
|
||||
(~social/content :content content))
|
||||
:original (when (and url (= post-type "remote"))
|
||||
(~federation-original-link :url url))
|
||||
(~social/original-link :url url))
|
||||
:interactions (when actor
|
||||
(let* ((csrf (csrf-token))
|
||||
(liked (get item "liked_by_me"))
|
||||
@@ -248,50 +248,50 @@
|
||||
(ainbox (or (get item "author_inbox") ""))
|
||||
(target (str "#interactions-" safe-id)))
|
||||
(div :id (str "interactions-" safe-id)
|
||||
(~federation-interaction-buttons
|
||||
:like (~federation-like-form
|
||||
(~social/interaction-buttons
|
||||
:like (~social/like-form
|
||||
:action (url-for (if liked "social.unlike" "social.like"))
|
||||
: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"))
|
||||
: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"))
|
||||
: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"))
|
||||
:count (str bcount))
|
||||
:reply (when oid
|
||||
(~federation-reply-link
|
||||
(~social/reply-link
|
||||
:url (url-for "social.defpage_compose_form" :reply-to oid))))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; 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)
|
||||
(~federation-post-card-from-data :item item :actor actor))
|
||||
(~social/post-card-from-data :item item :actor actor))
|
||||
items)
|
||||
(when next-url
|
||||
(~federation-scroll-sentinel :url next-url))))
|
||||
(~social/scroll-sentinel :url next-url))))
|
||||
|
||||
;; 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")))
|
||||
(~federation-timeline-page
|
||||
(~social/timeline-page
|
||||
:label label
|
||||
:compose (when actor
|
||||
(~federation-compose-button :url (url-for "social.defpage_compose_form")))
|
||||
:timeline (~federation-timeline-items
|
||||
(~social/compose-button :url (url-for "social.defpage_compose_form")))
|
||||
:timeline (~social/timeline-items
|
||||
:items items :timeline-type timeline-type :actor actor
|
||||
:next-url (when (not (empty? items))
|
||||
(url-for (str "social." timeline-type "_timeline_page")
|
||||
:before (get (last items) "before_cursor")))))))
|
||||
|
||||
;; Assembled compose content — replaces Python _compose_content_sx
|
||||
(defcomp ~federation-compose-content (&key (reply-to :as string?))
|
||||
(~federation-compose-form
|
||||
(defcomp ~social/compose-content (&key (reply-to :as string?))
|
||||
(~social/compose-form
|
||||
:action (url-for "social.compose_submit")
|
||||
:csrf (csrf-token)
|
||||
:reply (when reply-to
|
||||
(~federation-compose-reply :reply-to (escape reply-to)))))
|
||||
(~social/compose-reply :reply-to (escape reply-to)))))
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:auth :login
|
||||
:layout :social
|
||||
:data (service "federation-page" "home-timeline-data")
|
||||
:content (~federation-timeline-content
|
||||
:content (~social/timeline-content
|
||||
:items items
|
||||
:timeline-type timeline-type
|
||||
:actor actor))
|
||||
@@ -16,7 +16,7 @@
|
||||
:auth :public
|
||||
:layout :social
|
||||
:data (service "federation-page" "public-timeline-data")
|
||||
:content (~federation-timeline-content
|
||||
:content (~social/timeline-content
|
||||
:items items
|
||||
:timeline-type timeline-type
|
||||
:actor actor))
|
||||
@@ -26,7 +26,7 @@
|
||||
:auth :login
|
||||
:layout :social
|
||||
:data (service "federation-page" "compose-data")
|
||||
:content (~federation-compose-content
|
||||
:content (~social/compose-content
|
||||
:reply-to reply-to))
|
||||
|
||||
(defpage search
|
||||
@@ -34,7 +34,7 @@
|
||||
:auth :public
|
||||
:layout :social
|
||||
:data (service "federation-page" "search-data")
|
||||
:content (~federation-search-content
|
||||
:content (~search/content
|
||||
:query query
|
||||
:actors actors
|
||||
:total total
|
||||
@@ -46,7 +46,7 @@
|
||||
:auth :login
|
||||
:layout :social
|
||||
:data (service "federation-page" "following-data")
|
||||
:content (~federation-following-content
|
||||
:content (~search/following-content
|
||||
:actors actors
|
||||
:total total
|
||||
:actor actor))
|
||||
@@ -56,7 +56,7 @@
|
||||
:auth :login
|
||||
:layout :social
|
||||
:data (service "federation-page" "followers-data")
|
||||
:content (~federation-followers-content
|
||||
:content (~search/followers-content
|
||||
:actors actors
|
||||
:total total
|
||||
:followed-urls followed-urls
|
||||
@@ -67,7 +67,7 @@
|
||||
:auth :public
|
||||
:layout :social
|
||||
:data (service "federation-page" "actor-timeline-data" :id id)
|
||||
:content (~federation-actor-timeline-content
|
||||
:content (~profile/actor-timeline-content
|
||||
:remote-actor remote-actor
|
||||
:items items
|
||||
:is-following is-following
|
||||
@@ -78,5 +78,5 @@
|
||||
:auth :login
|
||||
:layout :social
|
||||
:data (service "federation-page" "notifications-data")
|
||||
:content (~federation-notifications-content
|
||||
:content (~notifications/content
|
||||
:notifications notifications))
|
||||
|
||||
@@ -27,7 +27,7 @@ async def _social_page(ctx: dict, actor, *, content: str,
|
||||
from markupsafe import escape
|
||||
|
||||
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,
|
||||
meta_html=meta_html or f'<title>{escape(title)}</title>')
|
||||
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
;; 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 ""
|
||||
: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"
|
||||
(figure :class "inline-block 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")
|
||||
(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))))
|
||||
|
||||
(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 "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")
|
||||
(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))))
|
||||
|
||||
(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)))
|
||||
|
||||
(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"
|
||||
(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))
|
||||
|
||||
;; Price — delegates to shared ~price
|
||||
(defcomp ~market-card-price (&key (special-price :as string?) (regular-price :as string?))
|
||||
(~price :special-price special-price :regular-price regular-price))
|
||||
;; Price — delegates to shared ~shared:misc/price
|
||||
(defcomp ~cards/price (&key (special-price :as string?) (regular-price :as string?))
|
||||
(~shared:misc/price :special-price special-price :regular-price regular-price))
|
||||
|
||||
;; 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?)
|
||||
(image :as string?) (labels :as list?) (brand :as string) (brand-highlight :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?))
|
||||
(div :class "flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative"
|
||||
(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")))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
(if image
|
||||
(~market-card-image :image image :labels labels :brand brand :brand-highlight brand-highlight)
|
||||
(~market-card-no-image :labels labels :brand brand))
|
||||
(~market-card-price :special-price special-price :regular-price regular-price))
|
||||
(~cards/image :image image :labels labels :brand brand :brand-highlight brand-highlight)
|
||||
(~cards/no-image :labels labels :brand brand))
|
||||
(~cards/price :special-price special-price :regular-price regular-price))
|
||||
(div :class "flex justify-center"
|
||||
(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))
|
||||
: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"
|
||||
: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]"
|
||||
(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)))))
|
||||
|
||||
(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"
|
||||
(form :id form-id :action action :method "post"
|
||||
:sx-post action :sx-target (str "#like-" slug) :sx-swap "outerHTML"
|
||||
@@ -73,22 +73,22 @@
|
||||
(button :type "submit" :class "cursor-pointer"
|
||||
(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"
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(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 ~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"
|
||||
(div
|
||||
(if title-content title-content (when title title))
|
||||
@@ -101,11 +101,11 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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?))
|
||||
(<>
|
||||
(map (lambda (p)
|
||||
(~market-product-card
|
||||
(~cards/product-card
|
||||
: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")
|
||||
:special-price (get p "special-price") :regular-price (get p "regular-price")
|
||||
@@ -119,39 +119,39 @@
|
||||
:search-post (get p "search-post")))
|
||||
products)
|
||||
(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)
|
||||
(~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))
|
||||
(~end-of-results))))
|
||||
(~shared:misc/end-of-results))))
|
||||
|
||||
;; 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?))
|
||||
(~market-market-card
|
||||
(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?))
|
||||
(~cards/market-card
|
||||
:title-content (if href
|
||||
(~market-market-card-title-link :href href :name name)
|
||||
(~market-market-card-title :name name))
|
||||
(~cards/market-card-title-link :href href :name name)
|
||||
(~cards/market-card-title :name name))
|
||||
:desc-content (when description
|
||||
(~market-market-card-desc :description description))
|
||||
(~cards/market-card-desc :description description))
|
||||
: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
|
||||
(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)
|
||||
(~market-card-from-data
|
||||
(~cards/from-data
|
||||
:name (get m "name") :description (get m "description")
|
||||
:href (get m "href") :show-badge (get m "show-badge")
|
||||
:badge-href (get m "badge-href") :badge-title (get m "badge-title")))
|
||||
markets)
|
||||
(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
|
||||
(defcomp ~market-landing-from-data (&key (excerpt :as string?) (feature-image :as string?) (html :as string?))
|
||||
(~market-landing-content :inner
|
||||
(<> (when excerpt (~market-landing-excerpt :text excerpt))
|
||||
(when feature-image (~market-landing-image :src feature-image))
|
||||
(when html (~market-landing-html :html html)))))
|
||||
(defcomp ~cards/landing-from-data (&key (excerpt :as string?) (feature-image :as string?) (html :as string?))
|
||||
(~detail/landing-content :inner
|
||||
(<> (when excerpt (~detail/landing-excerpt :text excerpt))
|
||||
(when feature-image (~detail/landing-image :src feature-image))
|
||||
(when html (~detail/landing-html :html html)))))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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
|
||||
(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)
|
||||
@@ -9,7 +9,7 @@
|
||||
(span :class "relative inline-flex items-center justify-center"
|
||||
(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 :class "rounded flex items-center gap-2"
|
||||
(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)
|
||||
(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"
|
||||
(a :href href :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"
|
||||
count))))))
|
||||
|
||||
(defcomp ~market-cart-mini-empty (&key href logo)
|
||||
(defcomp ~cart/mini-empty (&key href logo)
|
||||
(div :id "cart-mini" :sx-swap-oob "outerHTML"
|
||||
(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 ""))))
|
||||
|
||||
(defcomp ~market-cart-add-oob (&key id content inner)
|
||||
(defcomp ~cart/add-oob (&key id content inner)
|
||||
(div :id id :sx-swap-oob "outerHTML"
|
||||
(if content content (when inner inner))))
|
||||
|
||||
;; 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)
|
||||
(<>
|
||||
(if has-count
|
||||
(~market-cart-mini-count :href cart-href :count (str has-count))
|
||||
(~market-cart-mini-empty :href blog-href :logo logo))
|
||||
(~market-cart-add-oob :id (str "cart-add-" slug)
|
||||
(~cart/mini-count :href cart-href :count (str has-count))
|
||||
(~cart/mini-empty :href blog-href :logo logo))
|
||||
(~cart/add-oob :id (str "cart-add-" slug)
|
||||
:inner (if (= (or quantity "0") "0")
|
||||
(~market-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-empty :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
|
||||
:quantity quantity :cart-href cart-href)))))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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
|
||||
(figure :class "inline-block"
|
||||
(div :class "relative w-full aspect-square"
|
||||
@@ -9,7 +9,7 @@
|
||||
labels)
|
||||
(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 ""
|
||||
: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"
|
||||
: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"
|
||||
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 ""
|
||||
:class "shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"
|
||||
:title title
|
||||
(img :src src :class "h-16 w-16 object-contain" :alt alt :loading "lazy" :decoding "async"))
|
||||
(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 "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"
|
||||
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"))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~market-detail-unit-price (&key (price :as string))
|
||||
(defcomp ~detail/unit-price (&key (price :as string))
|
||||
(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)))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~market-detail-desc-short (&key (text :as string))
|
||||
(defcomp ~detail/desc-short (&key (text :as string))
|
||||
(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)))
|
||||
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(summary :class "cursor-pointer select-none px-4 py-3 flex items-center justify-between"
|
||||
(span :class "font-medium" title)
|
||||
(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))))
|
||||
|
||||
(defcomp ~market-detail-sections (&key (items :as list))
|
||||
(defcomp ~detail/sections (&key (items :as list))
|
||||
(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))
|
||||
|
||||
(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 "md:col-span-2" gallery stickers)
|
||||
details)
|
||||
(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))
|
||||
|
||||
(defcomp ~market-landing-image (&key (src :as string))
|
||||
(defcomp ~detail/landing-image (&key (src :as string))
|
||||
(div :class "mb-3 flex justify-center"
|
||||
(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)))
|
||||
|
||||
(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")))
|
||||
|
||||
|
||||
@@ -99,64 +99,64 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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
|
||||
(~market-like-button
|
||||
(~cards/like-button
|
||||
:form-id (get like-data "form-id") :action (get like-data "action")
|
||||
:slug (get like-data "slug") :csrf (get like-data "csrf")
|
||||
:icon-cls (get like-data "icon-cls")))))
|
||||
(if images
|
||||
(<>
|
||||
(~market-detail-gallery
|
||||
:inner (~market-detail-gallery-inner
|
||||
(~detail/gallery
|
||||
:inner (~detail/gallery-inner
|
||||
:like like-sx
|
||||
:image (get (first images) "src") :alt (get (first images) "alt")
|
||||
:labels (when labels
|
||||
(<> (map (lambda (src) (~market-label-overlay :src src)) labels)))
|
||||
(<> (map (lambda (src) (~cards/label-overlay :src src)) labels)))
|
||||
:brand brand)
|
||||
:nav (when has-nav-buttons (~market-detail-nav-buttons)))
|
||||
:nav (when has-nav-buttons (~detail/nav-buttons)))
|
||||
(when thumbs
|
||||
(~market-detail-thumbs :thumbs
|
||||
(~detail/thumbs :thumbs
|
||||
(<> (map (lambda (t)
|
||||
(~market-detail-thumb
|
||||
(~detail/thumb
|
||||
:title (get t "title") :src (get t "src") :alt (get t "alt")))
|
||||
thumbs)))))
|
||||
(~market-detail-no-image :like like-sx))))
|
||||
(~detail/no-image :like like-sx))))
|
||||
|
||||
;; 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?))
|
||||
(~market-detail-right-col :inner
|
||||
(defcomp ~detail/info-from-data (&key (extras :as list?) (desc-short :as string?) (desc-html :as string?) (sections :as list?))
|
||||
(~detail/right-col :inner
|
||||
(<>
|
||||
(when extras
|
||||
(~market-detail-extras :inner
|
||||
(~detail/extras :inner
|
||||
(<> (map (lambda (e)
|
||||
(if (= (get e "type") "unit-price")
|
||||
(~market-detail-unit-price :price (get e "value"))
|
||||
(~market-detail-case-size :size (get e "value"))))
|
||||
(~detail/unit-price :price (get e "value"))
|
||||
(~detail/case-size :size (get e "value"))))
|
||||
extras))))
|
||||
(when (or desc-short desc-html)
|
||||
(~market-detail-desc-wrapper :inner
|
||||
(<> (when desc-short (~market-detail-desc-short :text desc-short))
|
||||
(when desc-html (~market-detail-desc-html :html desc-html)))))
|
||||
(~detail/desc-wrapper :inner
|
||||
(<> (when desc-short (~detail/desc-short :text desc-short))
|
||||
(when desc-html (~detail/desc-html :html desc-html)))))
|
||||
(when sections
|
||||
(~market-detail-sections :items
|
||||
(~detail/sections :items
|
||||
(<> (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)))))))
|
||||
|
||||
;; 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?)
|
||||
(extras :as list?) (desc-short :as string?) (desc-html :as string?) (sections :as list?))
|
||||
(~market-detail-layout
|
||||
:gallery (~market-detail-gallery-from-data
|
||||
(~detail/layout
|
||||
:gallery (~detail/gallery-from-data
|
||||
:images images :labels labels :brand brand :like-data like-data
|
||||
:has-nav-buttons has-nav-buttons :thumbs thumbs)
|
||||
:stickers (when sticker-items
|
||||
(~market-detail-stickers :items
|
||||
(~detail/stickers :items
|
||||
(<> (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))))
|
||||
:details (~market-detail-info-from-data
|
||||
:details (~detail/info-from-data
|
||||
:extras extras :desc-short desc-short :desc-html desc-html
|
||||
:sections sections)))
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
;; 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"
|
||||
: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)
|
||||
(img :src src :alt label :class "w-10 h-10")
|
||||
(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"
|
||||
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"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "flex flex-col items-center gap-1 p-1 cursor-pointer"
|
||||
(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"
|
||||
: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)
|
||||
(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"
|
||||
: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)
|
||||
(img :src src :alt name :class "w-6 h-6")
|
||||
(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"
|
||||
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"
|
||||
: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)
|
||||
(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"
|
||||
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)))
|
||||
|
||||
(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"
|
||||
(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"
|
||||
(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))
|
||||
|
||||
(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"
|
||||
: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)
|
||||
name))
|
||||
|
||||
(defcomp ~market-filter-subcategory-panel (&key items)
|
||||
(defcomp ~filters/subcategory-panel (&key 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"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
: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"
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(summary :class "cursor-pointer select-none" :id "filter-summary-mobile"
|
||||
search-bar
|
||||
@@ -87,40 +87,40 @@
|
||||
(div :id "filter-details-mobile" :style "display:contents"
|
||||
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))
|
||||
|
||||
(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"
|
||||
(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"))
|
||||
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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"))
|
||||
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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"
|
||||
(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"
|
||||
(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))
|
||||
|
||||
|
||||
@@ -129,160 +129,160 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Sort option stickers from data
|
||||
(defcomp ~market-filter-sort-from-data (&key items)
|
||||
(~market-filter-sort-row :items
|
||||
(defcomp ~filters/sort-from-data (&key items)
|
||||
(~filters/sort-row :items
|
||||
(<> (map (lambda (s)
|
||||
(~market-filter-sort-item
|
||||
(~filters/sort-item
|
||||
:href (get s "href") :hx-select (get s "hx-select")
|
||||
:ring-cls (get s "ring-cls") :src (get s "src") :label (get s "label")))
|
||||
items))))
|
||||
|
||||
;; Like filter from data
|
||||
(defcomp ~market-filter-like-from-data (&key href hx-select liked mobile)
|
||||
(~market-filter-like
|
||||
(defcomp ~filters/like-from-data (&key href hx-select liked mobile)
|
||||
(~filters/like
|
||||
:href href :hx-select hx-select
|
||||
: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")))
|
||||
|
||||
;; 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)
|
||||
(~market-filter-label-item
|
||||
(~filters/label-item
|
||||
:href (get lb "href") :hx-select hx-select
|
||||
:ring-cls (get lb "ring-cls") :src (get lb "src") :name (get lb "name")))
|
||||
items)))
|
||||
|
||||
;; Sticker filter items from data
|
||||
(defcomp ~market-filter-stickers-from-data (&key items hx-select)
|
||||
(~market-filter-stickers-row :items
|
||||
(defcomp ~filters/stickers-from-data (&key items hx-select)
|
||||
(~filters/stickers-row :items
|
||||
(<> (map (lambda (st)
|
||||
(~market-filter-sticker-item
|
||||
(~filters/sticker-item
|
||||
:href (get st "href") :hx-select hx-select
|
||||
:ring-cls (get st "ring-cls") :src (get st "src") :name (get st "name")
|
||||
:count-cls (get st "count-cls") :count (get st "count")))
|
||||
items))))
|
||||
|
||||
;; Brand filter items from data
|
||||
(defcomp ~market-filter-brands-from-data (&key items hx-select)
|
||||
(~market-filter-brands-panel :items
|
||||
(defcomp ~filters/brands-from-data (&key items hx-select)
|
||||
(~filters/brands-panel :items
|
||||
(<> (map (lambda (br)
|
||||
(~market-filter-brand-item
|
||||
(~filters/brand-item
|
||||
:href (get br "href") :hx-select hx-select
|
||||
:bg-cls (get br "bg-cls") :name-cls (get br "name-cls")
|
||||
:name (get br "name") :count (get br "count")))
|
||||
items))))
|
||||
|
||||
;; Subcategory selector from data
|
||||
(defcomp ~market-filter-subcategories-from-data (&key items hx-select all-href current-sub)
|
||||
(~market-filter-subcategory-panel :items
|
||||
(defcomp ~filters/subcategories-from-data (&key items hx-select all-href current-sub)
|
||||
(~filters/subcategory-panel :items
|
||||
(<>
|
||||
(~market-filter-subcategory-item
|
||||
(~filters/subcategory-item
|
||||
:href all-href :hx-select hx-select
|
||||
:active-cls (if (not current-sub) " bg-stone-200 font-medium" "")
|
||||
:name "All")
|
||||
(map (lambda (sub)
|
||||
(~market-filter-subcategory-item
|
||||
(~filters/subcategory-item
|
||||
:href (get sub "href") :hx-select hx-select
|
||||
:active-cls (get sub "active-cls") :name (get sub "name")))
|
||||
items))))
|
||||
|
||||
;; 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
|
||||
sticker-data brand-data sub-data hx-select)
|
||||
(<>
|
||||
search-sx
|
||||
(~market-desktop-category-summary :inner
|
||||
(~filters/desktop-category-summary :inner
|
||||
(<>
|
||||
(~market-filter-category-label :label category-label)
|
||||
(when sort-data (~market-filter-sort-from-data :items sort-data))
|
||||
(~market-filter-like-labels-nav :inner
|
||||
(~filters/category-label :label category-label)
|
||||
(when sort-data (~filters/sort-from-data :items sort-data))
|
||||
(~filters/like-labels-nav :inner
|
||||
(<>
|
||||
(~market-filter-like-from-data
|
||||
(~filters/like-from-data
|
||||
:href (get like-data "href") :hx-select hx-select
|
||||
:liked (get like-data "liked") :mobile false)
|
||||
(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
|
||||
(~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
|
||||
(~market-filter-subcategories-from-data
|
||||
(~filters/subcategories-from-data
|
||||
:items (get sub-data "items") :hx-select hx-select
|
||||
:all-href (get sub-data "all-href")
|
||||
:current-sub (get sub-data "current-sub")))))
|
||||
(~market-desktop-brand-summary
|
||||
(~filters/desktop-brand-summary
|
||||
: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
|
||||
(defcomp ~market-mobile-chips-from-data (&key sort-chip liked-chip label-chips sticker-chips brand-chips)
|
||||
(~market-mobile-chips-row :inner
|
||||
(defcomp ~filters/mobile-chips-from-data (&key sort-chip liked-chip label-chips sticker-chips brand-chips)
|
||||
(~filters/mobile-chips-row :inner
|
||||
(<>
|
||||
(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
|
||||
(~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")
|
||||
(~market-mobile-chip-count
|
||||
(~filters/mobile-chip-count
|
||||
:cls (get liked-chip "count-cls") :count (get liked-chip "count"))))))
|
||||
(when label-chips
|
||||
(~market-mobile-chip-list :items
|
||||
(~filters/mobile-chip-list :items
|
||||
(<> (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")
|
||||
(~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))))
|
||||
(when sticker-chips
|
||||
(~market-mobile-chip-list :items
|
||||
(~filters/mobile-chip-list :items
|
||||
(<> (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")
|
||||
(~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))))
|
||||
(when brand-chips
|
||||
(~market-mobile-chip-brand-list :items
|
||||
(~filters/mobile-chip-brand-list :items
|
||||
(<> (map (lambda (bc)
|
||||
(if (get bc "has-count")
|
||||
(~market-mobile-chip-brand :name (get bc "name") :count (get bc "count"))
|
||||
(~market-mobile-chip-brand-zero :name (get bc "name"))))
|
||||
(~filters/mobile-chip-brand :name (get bc "name") :count (get bc "count"))
|
||||
(~filters/mobile-chip-brand-zero :name (get bc "name"))))
|
||||
brand-chips)))))))
|
||||
|
||||
;; 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)
|
||||
(<>
|
||||
(when sort-data (~market-filter-sort-from-data :items sort-data))
|
||||
(when sort-data (~filters/sort-from-data :items sort-data))
|
||||
(when clear-href
|
||||
(~market-mobile-clear-filters :href clear-href :hx-select hx-select))
|
||||
(~market-mobile-like-labels-row :inner
|
||||
(~filters/mobile-clear-filters :href clear-href :hx-select hx-select))
|
||||
(~filters/mobile-like-labels-row :inner
|
||||
(<>
|
||||
(~market-filter-like-from-data
|
||||
(~filters/like-from-data
|
||||
:href (get like-data "href") :hx-select hx-select
|
||||
:liked (get like-data "liked") :mobile true)
|
||||
(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
|
||||
(~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
|
||||
(~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)
|
||||
(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-data like-data label-data sticker-data brand-data
|
||||
clear-href hx-select)
|
||||
(~market-mobile-filter-summary
|
||||
(~filters/mobile-filter-summary
|
||||
:search-bar search-bar
|
||||
:chips (~market-mobile-chips-from-data
|
||||
:chips (~filters/mobile-chips-from-data
|
||||
:sort-chip sort-chip :liked-chip liked-chip
|
||||
: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
|
||||
:label-data label-data :sticker-data sticker-data :brand-data brand-data
|
||||
:clear-href clear-href :hx-select hx-select)))
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
;; 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")))
|
||||
|
||||
(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")))
|
||||
|
||||
(defcomp ~market-admin-content-wrap (&key inner)
|
||||
(defcomp ~grids/admin-content-wrap (&key 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]")
|
||||
:sx-post action :sx-target "this" :sx-swap "outerHTML" :sx-push-url "false"
|
||||
:sx-headers hx-headers
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
(sel-colours (or (jinja-global "select_colours") "")))
|
||||
(<> (map (fn (m)
|
||||
(let ((href (app-url "market" (str "/" slug "/" (get m "slug") "/"))))
|
||||
(~market-link-nav
|
||||
(~shared:navigation/market-link-nav
|
||||
:href href
|
||||
:name (get m "name")
|
||||
:nav-class nav-class
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
(if (get product "regular_price")
|
||||
(str (get product "regular_price"))
|
||||
""))))
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:title (get product "title")
|
||||
:image (get product "image")
|
||||
:subtitle subtitle
|
||||
@@ -35,7 +35,7 @@
|
||||
(if (get product "regular_price")
|
||||
(str (get product "regular_price"))
|
||||
""))))
|
||||
(~link-card
|
||||
(~shared:fragments/link-card
|
||||
:title (get product "title")
|
||||
:image (get product "image")
|
||||
:subtitle subtitle
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
;; 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 (i :class "fa fa-shop") " " title)
|
||||
(div :class "flex flex-col md:flex-row md:gap-2 text-xs"
|
||||
(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)))
|
||||
|
||||
(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"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "px-2 py-1 text-stone-500 hover:text-stone-700"
|
||||
@@ -21,42 +21,42 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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)
|
||||
(~market-desktop-category-nav
|
||||
(~navigation/desktop-category-nav
|
||||
: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")
|
||||
(map (lambda (cat)
|
||||
(~market-category-link
|
||||
(~navigation/category-link
|
||||
:href (get cat "href") :hx-select hx-select
|
||||
:active (get cat "active") :select-colours select-colours
|
||||
:label (get cat "label"))) categories))
|
||||
: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
|
||||
(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
|
||||
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-label-content (~market-shop-label
|
||||
:link-label-content (~headers/shop-label
|
||||
: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
|
||||
:all-href all-href :all-active all-active :admin-href admin-href)
|
||||
:child-id "market-header-child"
|
||||
:oob oob))
|
||||
|
||||
;; 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)
|
||||
(~menu-row-sx :id "product-row" :level 3
|
||||
(~shared:layout/menu-row-sx :id "product-row" :level 3
|
||||
:link-href link-href
|
||||
:link-label-content (~market-product-label :title title)
|
||||
:link-label-content (~headers/product-label :title title)
|
||||
:nav (<>
|
||||
(~market-prices-header-from-data
|
||||
(~prices/header-from-data
|
||||
:cart-id (get price-data "cart-id")
|
||||
:cart-action (get price-data "cart-action")
|
||||
:csrf (get price-data "csrf")
|
||||
@@ -66,13 +66,13 @@
|
||||
:rp-val (get price-data "rp-val") :rp-str (get price-data "rp-str")
|
||||
:rrp-str (get price-data "rrp-str"))
|
||||
(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"
|
||||
:oob oob))
|
||||
|
||||
;; Product admin header row from data
|
||||
(defcomp ~market-product-admin-header-from-data (&key link-href oob)
|
||||
(~menu-row-sx :id "product-admin-row" :level 4
|
||||
(defcomp ~headers/product-admin-header-from-data (&key link-href oob)
|
||||
(~shared:layout/menu-row-sx :id "product-admin-row" :level 4
|
||||
:link-href link-href :link-label "admin!!" :icon "fa fa-cog"
|
||||
:child-id "product-admin-header-child" :oob oob))
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
"Market header row using (market-header-ctx)."
|
||||
(quasiquote
|
||||
(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-label-content (~market-shop-label
|
||||
:link-label-content (~headers/shop-label
|
||||
:title (get __mctx "market-title")
|
||||
:top-slug (get __mctx "top-slug")
|
||||
:sub-div (get __mctx "sub-slug"))
|
||||
:nav (~market-desktop-nav-from-data
|
||||
:nav (~headers/desktop-nav-from-data
|
||||
:categories (get __mctx "categories")
|
||||
:hx-select (get __mctx "hx-select")
|
||||
:select-colours (get __mctx "select-colours")
|
||||
@@ -29,44 +29,44 @@
|
||||
;; OOB clear helpers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-clear-oob ()
|
||||
(defcomp ~layouts/clear-oob ()
|
||||
"Clear OOB divs for browse level."
|
||||
(<>
|
||||
(~clear-oob-div :id "product-admin-row")
|
||||
(~clear-oob-div :id "product-admin-header-child")
|
||||
(~clear-oob-div :id "product-row")
|
||||
(~clear-oob-div :id "product-header-child")
|
||||
(~clear-oob-div :id "market-admin-row")
|
||||
(~clear-oob-div :id "market-admin-header-child")
|
||||
(~clear-oob-div :id "post-admin-row")
|
||||
(~clear-oob-div :id "post-admin-header-child")))
|
||||
(~shared:layout/clear-oob-div :id "product-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "product-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "product-row")
|
||||
(~shared:layout/clear-oob-div :id "product-header-child")
|
||||
(~shared:layout/clear-oob-div :id "market-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "market-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "post-admin-row")
|
||||
(~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-div :id "product-admin-row")
|
||||
(~clear-oob-div :id "product-admin-header-child")
|
||||
(~clear-oob-div :id "product-row")
|
||||
(~clear-oob-div :id "product-header-child")))
|
||||
(~shared:layout/clear-oob-div :id "product-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "product-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "product-row")
|
||||
(~shared:layout/clear-oob-div :id "product-header-child")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Browse layout: root + post + market (self-contained)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-browse-layout-full ()
|
||||
(defcomp ~layouts/browse-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~market-header-auto nil)))))
|
||||
|
||||
(defcomp ~market-browse-layout-oob ()
|
||||
(defcomp ~layouts/browse-layout-oob ()
|
||||
(<> (~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))
|
||||
(~market-clear-oob)
|
||||
(~layouts/clear-oob)
|
||||
(~root-header-auto true)))
|
||||
|
||||
(defcomp ~market-browse-layout-mobile ()
|
||||
(defcomp ~layouts/browse-layout-mobile ()
|
||||
(let ((__mctx (market-header-ctx)))
|
||||
(get __mctx "mobile-nav")))
|
||||
|
||||
@@ -74,18 +74,18 @@
|
||||
;; 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~market-header-auto nil)
|
||||
(~post-admin-header-auto nil selected)))))
|
||||
|
||||
(defcomp ~market-admin-layout-oob (&key selected)
|
||||
(defcomp ~layouts/admin-layout-oob (&key selected)
|
||||
(<> (~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))
|
||||
(~market-clear-oob-admin)
|
||||
(~layouts/clear-oob-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -93,46 +93,46 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; 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)
|
||||
(~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
|
||||
(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)
|
||||
(~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)
|
||||
|
||||
(defcomp ~market-oob-wrap (&key parts)
|
||||
(defcomp ~layouts/oob-wrap (&key parts)
|
||||
(<> parts))
|
||||
|
||||
(defcomp ~market-clear-product-oob ()
|
||||
(defcomp ~layouts/clear-product-oob ()
|
||||
"Clear admin-level OOB divs when rendering product detail."
|
||||
(<>
|
||||
(~clear-oob-div :id "product-admin-row")
|
||||
(~clear-oob-div :id "product-admin-header-child")
|
||||
(~clear-oob-div :id "market-admin-row")
|
||||
(~clear-oob-div :id "market-admin-header-child")
|
||||
(~clear-oob-div :id "post-admin-row")
|
||||
(~clear-oob-div :id "post-admin-header-child")))
|
||||
(~shared:layout/clear-oob-div :id "product-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "product-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "market-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "market-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "post-admin-row")
|
||||
(~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-oob-div :id "market-admin-row")
|
||||
(~clear-oob-div :id "market-admin-header-child")
|
||||
(~clear-oob-div :id "post-admin-row")
|
||||
(~clear-oob-div :id "post-admin-header-child")))
|
||||
(~shared:layout/clear-oob-div :id "market-admin-row")
|
||||
(~shared:layout/clear-oob-div :id "market-admin-header-child")
|
||||
(~shared:layout/clear-oob-div :id "post-admin-row")
|
||||
(~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."
|
||||
(<> 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-header oob-header (~market-clear-product-admin-oob)))
|
||||
(<> product-header oob-header (~layouts/clear-product-admin-oob)))
|
||||
|
||||
;; Content wrappers
|
||||
(defcomp ~market-content-padded (&key content)
|
||||
(defcomp ~layouts/content-padded (&key content)
|
||||
(<> content (div :class "pb-8")))
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
;; Market meta/SEO components
|
||||
|
||||
(defcomp ~market-meta-title (&key (title :as string))
|
||||
(defcomp ~meta/title (&key (title :as string))
|
||||
(title title))
|
||||
|
||||
(defcomp ~market-meta-description (&key (description :as string))
|
||||
(defcomp ~meta/description (&key (description :as string))
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~market-meta-jsonld (&key (json :as string))
|
||||
(defcomp ~meta/jsonld (&key (json :as string))
|
||||
(script :type "application/ld+json" (~rich-text :html json)))
|
||||
|
||||
|
||||
@@ -23,30 +23,30 @@
|
||||
;; 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?)
|
||||
(site-title :as string) (brand :as string?) (price :as string?) (price-currency :as string?)
|
||||
(jsonld-json :as string))
|
||||
(<>
|
||||
(~market-meta-title :title title)
|
||||
(~market-meta-description :description description)
|
||||
(when canonical (~market-meta-canonical :href canonical))
|
||||
(~meta/title :title title)
|
||||
(~meta/description :description description)
|
||||
(when canonical (~meta/canonical :href canonical))
|
||||
;; OpenGraph
|
||||
(~market-meta-og :property "og:site_name" :content site-title)
|
||||
(~market-meta-og :property "og:type" :content "product")
|
||||
(~market-meta-og :property "og:title" :content title)
|
||||
(~market-meta-og :property "og:description" :content description)
|
||||
(when canonical (~market-meta-og :property "og:url" :content canonical))
|
||||
(when image-url (~market-meta-og :property "og:image" :content image-url))
|
||||
(~meta/og :property "og:site_name" :content site-title)
|
||||
(~meta/og :property "og:type" :content "product")
|
||||
(~meta/og :property "og:title" :content title)
|
||||
(~meta/og :property "og:description" :content description)
|
||||
(when canonical (~meta/og :property "og:url" :content canonical))
|
||||
(when image-url (~meta/og :property "og:image" :content image-url))
|
||||
(when (and price price-currency)
|
||||
(<> (~market-meta-og :property "product:price:amount" :content price)
|
||||
(~market-meta-og :property "product:price:currency" :content price-currency)))
|
||||
(when brand (~market-meta-og :property "product:brand" :content brand))
|
||||
(<> (~meta/og :property "product:price:amount" :content price)
|
||||
(~meta/og :property "product:price:currency" :content price-currency)))
|
||||
(when brand (~meta/og :property "product:brand" :content brand))
|
||||
;; Twitter
|
||||
(~market-meta-twitter :name "twitter:card"
|
||||
(~meta/twitter :name "twitter:card"
|
||||
:content (if image-url "summary_large_image" "summary"))
|
||||
(~market-meta-twitter :name "twitter:title" :content title)
|
||||
(~market-meta-twitter :name "twitter:description" :content description)
|
||||
(when image-url (~market-meta-twitter :name "twitter:image" :content image-url))
|
||||
(~meta/twitter :name "twitter:title" :content title)
|
||||
(~meta/twitter :name "twitter:description" :content description)
|
||||
(when image-url (~meta/twitter :name "twitter:image" :content image-url))
|
||||
;; JSON-LD
|
||||
(~market-meta-jsonld :json jsonld-json)))
|
||||
(~meta/jsonld :json jsonld-json)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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"
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
: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)
|
||||
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"
|
||||
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)))
|
||||
|
||||
(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"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
:aria-selected (if active "true" "false")
|
||||
: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")))
|
||||
|
||||
(defcomp ~market-mobile-chevron ()
|
||||
(defcomp ~navigation/mobile-chevron ()
|
||||
(svg :class "w-4 h-4 shrink-0 transition-transform group-open/cat:rotate-180"
|
||||
:viewBox "0 0 20 20" :fill "currentColor"
|
||||
(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")))
|
||||
|
||||
(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)
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
@@ -37,7 +37,7 @@
|
||||
(div :aria-label count-label count-str))
|
||||
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")
|
||||
:aria-selected (if active "true" "false")
|
||||
:href href :sx-get href :sx-target "#main-panel"
|
||||
@@ -45,20 +45,20 @@
|
||||
(div label)
|
||||
(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 :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"
|
||||
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"
|
||||
(a :class "px-2 py-1 rounded hover:bg-stone-100 block"
|
||||
:href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
|
||||
"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
|
||||
summary subs))
|
||||
|
||||
@@ -67,25 +67,25 @@
|
||||
;; 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))
|
||||
(~market-mobile-nav-wrapper :items
|
||||
(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))
|
||||
(~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)
|
||||
(map (lambda (cat)
|
||||
(~market-mobile-cat-details
|
||||
(~navigation/mobile-cat-details
|
||||
: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" "")
|
||||
:href (get cat "href") :hx-select hx-select
|
||||
:select-colours select-colours :cat-name (get cat "name")
|
||||
:count-label (str (get cat "count") " products")
|
||||
:count-str (str (get cat "count"))
|
||||
:chevron (~market-mobile-chevron))
|
||||
:chevron (~navigation/mobile-chevron))
|
||||
:subs (if (get cat "subs")
|
||||
(~market-mobile-subs-panel :links
|
||||
(~navigation/mobile-subs-panel :links
|
||||
(<> (map (lambda (sub)
|
||||
(~market-mobile-sub-link
|
||||
(~navigation/mobile-sub-link
|
||||
:select-colours select-colours
|
||||
:active (get sub "active")
|
||||
:href (get sub "href") :hx-select hx-select
|
||||
@@ -93,5 +93,5 @@
|
||||
:count-label (str (get sub "count") " products")
|
||||
:count-str (str (get sub "count"))))
|
||||
(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))))
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
;; 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))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~market-price-regular (&key (price :as string))
|
||||
(defcomp ~prices/regular (&key (price :as string))
|
||||
(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))
|
||||
|
||||
(defcomp ~market-header-price-special-label ()
|
||||
(defcomp ~prices/header-price-special-label ()
|
||||
(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))
|
||||
|
||||
(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))
|
||||
|
||||
(defcomp ~market-header-price-regular-label ()
|
||||
(defcomp ~prices/header-price-regular-label ()
|
||||
(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))
|
||||
|
||||
(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)))
|
||||
|
||||
(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))
|
||||
|
||||
|
||||
@@ -38,31 +38,31 @@
|
||||
;; 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)
|
||||
(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
|
||||
(~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))
|
||||
: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
|
||||
(<> (~market-header-price-special-label)
|
||||
(~market-header-price-special :price sp-str)
|
||||
(when rp-val (~market-header-price-strike :price rp-str))))
|
||||
(<> (~prices/header-price-special-label)
|
||||
(~prices/header-price-special :price sp-str)
|
||||
(when rp-val (~prices/header-price-strike :price rp-str))))
|
||||
(when (and (not sp-val) rp-val)
|
||||
(<> (~market-header-price-regular-label)
|
||||
(~market-header-price-regular :price rp-str)))
|
||||
(when rrp-str (~market-header-rrp :rrp rrp-str)))))
|
||||
(<> (~prices/header-price-regular-label)
|
||||
(~prices/header-price-regular :price rp-str)))
|
||||
(when rrp-str (~prices/header-rrp :rrp rrp-str)))))
|
||||
|
||||
;; 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?))
|
||||
(~market-price-line :inner
|
||||
(defcomp ~prices/card-price-from-data (&key (sp-val :as number?) (sp-str :as string?) (rp-val :as number?) (rp-str :as string?))
|
||||
(~prices/line :inner
|
||||
(<>
|
||||
(when sp-val
|
||||
(<> (~market-price-special :price sp-str)
|
||||
(when rp-val (~market-price-regular-strike :price rp-str))))
|
||||
(<> (~prices/special :price sp-str)
|
||||
(when rp-val (~prices/regular-strike :price rp-str))))
|
||||
(when (and (not sp-val) rp-val)
|
||||
(~market-price-regular :price rp-str)))))
|
||||
(~prices/regular :price rp-str)))))
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
:layout :root
|
||||
:data (all-markets-data)
|
||||
: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")
|
||||
(~market-markets-grid
|
||||
:cards (~market-cards-content
|
||||
(~grids/markets-grid
|
||||
:cards (~cards/content
|
||||
:markets market-data :page market-page
|
||||
:has-more has-more :next-url next-url))))
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
:layout :post
|
||||
:data (page-markets-data)
|
||||
: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")
|
||||
(~market-markets-grid
|
||||
:cards (~market-cards-content
|
||||
(~grids/markets-grid
|
||||
:cards (~cards/content
|
||||
:markets market-data :page market-page
|
||||
:has-more has-more :next-url next-url))))
|
||||
|
||||
@@ -38,24 +38,24 @@
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "markets")
|
||||
:data (page-admin-data)
|
||||
:content (~market-admin-content-wrap
|
||||
:inner (~crud-panel
|
||||
:content (~grids/admin-content-wrap
|
||||
:inner (~shared:misc/crud-panel
|
||||
:list-id "markets-list"
|
||||
:form (when can-create
|
||||
(~crud-create-form
|
||||
(~shared:misc/crud-create-form
|
||||
:create-url create-url :csrf csrf
|
||||
:errors-id "market-create-errors" :list-id "markets-list"
|
||||
:placeholder "e.g. Suma, Craft Fair" :btn-label "Add market"))
|
||||
:list (if admin-markets
|
||||
(<> (map (fn (m)
|
||||
(~crud-item
|
||||
(~shared:misc/crud-item
|
||||
:href (get m "href") :name (get m "name") :slug (get m "slug")
|
||||
:del-url (get m "del-url") :csrf-hdr (get m "csrf-hdr")
|
||||
:list-id "markets-list"
|
||||
:confirm-title "Delete market?"
|
||||
:confirm-text "Products will be hidden (soft delete)"))
|
||||
admin-markets))
|
||||
(~empty-state
|
||||
(~shared:misc/empty-state
|
||||
:message "No markets yet. Create one above."
|
||||
:cls "text-gray-500 mt-4")))))
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
:auth :public
|
||||
:layout :market
|
||||
:data (market-home-data)
|
||||
:content (~market-landing-from-data
|
||||
:content (~cards/landing-from-data
|
||||
:excerpt excerpt :feature-image feature-image :html html))
|
||||
|
||||
(defpage market-admin
|
||||
|
||||
@@ -30,7 +30,7 @@ async def render_browse_page(ctx: dict) -> str:
|
||||
content = sx_call("market-product-grid", cards=SxExpr(_product_cards_sx(ctx)))
|
||||
|
||||
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)
|
||||
filter_sx = await _mobile_filter_summary_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)
|
||||
|
||||
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),
|
||||
market_header=_market_header_sx(ctx),
|
||||
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)
|
||||
|
||||
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),
|
||||
market_header=_market_header_sx(ctx),
|
||||
product_header=_product_header_sx(ctx, d),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; 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"
|
||||
(div :class "space-y-1"
|
||||
(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.")
|
||||
(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 "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.")))
|
||||
|
||||
(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"
|
||||
(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 "
|
||||
(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"
|
||||
(p :class "font-medium" "All done!")
|
||||
(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"
|
||||
(div
|
||||
(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 "ml-4 font-medium" price)))
|
||||
|
||||
(defcomp ~checkout-return-tickets (&key items)
|
||||
(defcomp ~checkout/return-tickets (&key items)
|
||||
(section :class "mt-6 space-y-3"
|
||||
(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)))
|
||||
|
||||
;; Data-driven ticket items (replaces Python loop)
|
||||
(defcomp ~checkout-return-tickets-from-data (&key (tickets :as list))
|
||||
(~checkout-return-tickets
|
||||
(defcomp ~checkout/return-tickets-from-data (&key (tickets :as list))
|
||||
(~checkout/return-tickets
|
||||
:items (<> (map (lambda (tk)
|
||||
(~checkout-return-ticket
|
||||
(~checkout/return-ticket
|
||||
:name (get tk "name") :pill (get tk "pill")
|
||||
:state (get tk "state") :type-name (get tk "type_name")
|
||||
:date-str (get tk "date_str") :code (get tk "code")
|
||||
:price (get tk "price")))
|
||||
(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"
|
||||
(when summary
|
||||
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2" summary))
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
;; Renders the "orders" link for the account dashboard nav.
|
||||
|
||||
(defhandler account-nav-item (&key)
|
||||
(~account-nav-item
|
||||
(~shared:fragments/account-nav-item
|
||||
:href (app-url "orders" "/")
|
||||
:label "orders"))
|
||||
|
||||
@@ -3,40 +3,40 @@
|
||||
|
||||
;; --- 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)
|
||||
(~header-child-sx
|
||||
(~shared:layout/header-child-sx
|
||||
: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)
|
||||
(~oob-header-sx
|
||||
(~shared:layout/oob-header-sx
|
||||
: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)))
|
||||
|
||||
(defcomp ~orders-layout-mobile ()
|
||||
(defcomp ~layouts/mobile ()
|
||||
(~root-mobile-auto))
|
||||
|
||||
;; --- 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)
|
||||
(~order-detail-header-stack
|
||||
(~shared:orders/detail-header-stack
|
||||
:auth (~auth-header-row-auto)
|
||||
:orders (~orders-header-row :list-url (or list-url "/"))
|
||||
:order (~menu-row-sx :id "order-row" :level 3 :colour "sky"
|
||||
:orders (~shared:auth/orders-header-row :list-url (or list-url "/"))
|
||||
:order (~shared:layout/menu-row-sx :id "order-row" :level 3 :colour "sky"
|
||||
:link-href (or detail-url "/") :link-label "Order"
|
||||
:icon "fa fa-gbp"))))
|
||||
|
||||
(defcomp ~order-detail-layout-oob (&key (detail-url :as string))
|
||||
(<> (~oob-header-sx
|
||||
(defcomp ~layouts/order-detail-layout-oob (&key (detail-url :as string))
|
||||
(<> (~shared:layout/oob-header-sx
|
||||
: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"
|
||||
:icon "fa fa-gbp" :oob true))
|
||||
(~root-header-auto true)))
|
||||
|
||||
(defcomp ~order-detail-layout-mobile ()
|
||||
(defcomp ~layouts/order-detail-layout-mobile ()
|
||||
(~root-mobile-auto))
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
:page (or (request-arg "page" "1") "1"))
|
||||
:layout (:orders
|
||||
:list-url (str (route-prefix) (url-for "defpage_orders_list")))
|
||||
:filter (~order-list-header
|
||||
:search-mobile (~search-mobile
|
||||
:filter (~shared:orders/list-header
|
||||
:search-mobile (~shared:controls/search-mobile
|
||||
:current-local-href "/"
|
||||
:search (or search "")
|
||||
:search-count (or search-count "")
|
||||
:hx-select "#main-panel"
|
||||
:search-headers-mobile "{\"X-Origin\":\"search-mobile\",\"X-Search\":\"true\"}"))
|
||||
:aside (~search-desktop
|
||||
:aside (~shared:controls/search-desktop
|
||||
:current-local-href "/"
|
||||
:search (or search "")
|
||||
:search-count (or search-count "")
|
||||
@@ -30,7 +30,7 @@
|
||||
(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)))
|
||||
(rows-url (str pfx (url-for "orders.orders_rows"))))
|
||||
(~orders-list-content
|
||||
(~shared:orders/list-content
|
||||
:orders orders
|
||||
:page page
|
||||
:total-pages total-pages
|
||||
@@ -49,12 +49,12 @@
|
||||
:list-url (str (route-prefix) (url-for "defpage_orders_list"))
|
||||
:detail-url (str (route-prefix) (url-for "defpage_order_detail" :order-id order-id)))
|
||||
:filter (let* ((pfx (route-prefix)))
|
||||
(~order-detail-filter-content
|
||||
(~shared:orders/detail-filter-content
|
||||
:order order
|
||||
:list-url (str pfx (url-for "defpage_orders_list"))
|
||||
: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))
|
||||
:csrf (csrf-token)))
|
||||
:content (~order-detail-content
|
||||
:content (~shared:orders/detail-content
|
||||
:order order
|
||||
:calendar-entries calendar-entries))
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
(href (if svc-name
|
||||
(app-url svc-name path)
|
||||
path)))
|
||||
(~relation-nav
|
||||
(~shared:navigation/relation-nav
|
||||
:href href
|
||||
:name (or (get child "label") "")
|
||||
:icon (or (get defn "nav_icon") "")
|
||||
|
||||
@@ -62,7 +62,7 @@ def _sx_error_page(errnum: str, message: str, image: str | None = None) -> str:
|
||||
from shared.sx.page import 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",
|
||||
message=message,
|
||||
image=image,
|
||||
|
||||
@@ -118,6 +118,11 @@ def create_base_app(
|
||||
setup_jinja(app)
|
||||
setup_sx_bridge(app)
|
||||
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 defquery/defaction definitions from {service}/queries.sx and actions.sx
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,14 +13,18 @@ from .jinja_bridge import load_sx_dir, register_reload_callback, watch_sx_dir
|
||||
|
||||
|
||||
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
|
||||
# before any .sx file that uses them is parsed
|
||||
_load_sx_libraries()
|
||||
register_reload_callback(_load_sx_libraries)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -32,4 +36,4 @@ def _load_sx_libraries() -> None:
|
||||
path = os.path.join(ref_dir, name)
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
register_components(f.read())
|
||||
register_components(f.read(), _defer_postprocess=True)
|
||||
|
||||
@@ -126,7 +126,7 @@ def _compute_all_io_refs_fallback(
|
||||
|
||||
def _scan_components_from_sx_fallback(source: str) -> set[str]:
|
||||
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]:
|
||||
@@ -170,7 +170,7 @@ def compute_all_deps(env: dict[str, Any]) -> None:
|
||||
def scan_components_from_sx(source: str) -> set[str]:
|
||||
"""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():
|
||||
from .ref.sx_ref import scan_components_from_source as _ref_sc
|
||||
|
||||
@@ -24,11 +24,25 @@ import logging
|
||||
import os
|
||||
from typing import Any, Callable, Awaitable
|
||||
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from .types import HandlerDef
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -229,6 +243,10 @@ def register_route_handlers(app_or_bp: Any, service_name: str) -> int:
|
||||
from quart import Response, request
|
||||
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)
|
||||
count = 0
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
rights = ctx.get("rights") or {}
|
||||
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 ""
|
||||
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")),
|
||||
blog_url=call_url(ctx, "blog_url", ""),
|
||||
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:
|
||||
"""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 ""
|
||||
auth_menu = ctx.get("auth_menu") or ""
|
||||
if not nav_tree and not auth_menu:
|
||||
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),
|
||||
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)
|
||||
if page_cart_count and page_cart_count > 0:
|
||||
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)))
|
||||
|
||||
container_nav = str(ctx.get("container_nav") or "").strip()
|
||||
# Skip empty fragment wrappers like "(<> )"
|
||||
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)))
|
||||
|
||||
# Admin cog
|
||||
@@ -134,7 +134,7 @@ async def _post_nav_items_sx(ctx: dict) -> SxExpr:
|
||||
from quart import request
|
||||
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
|
||||
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,
|
||||
is_admin_page=is_admin_page or None)
|
||||
if admin_nav:
|
||||
@@ -164,7 +164,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
|
||||
continue
|
||||
href = url_fn(path)
|
||||
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,
|
||||
is_selected=is_sel or None))
|
||||
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 {}
|
||||
slug = post.get("slug", "")
|
||||
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,
|
||||
href=call_url(ctx, "blog_url", f"/{slug}/"),
|
||||
level=1,
|
||||
@@ -193,7 +193,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
|
||||
|
||||
async def search_mobile_sx(ctx: dict) -> str:
|
||||
"""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", "/"),
|
||||
search=ctx.get("search", ""),
|
||||
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:
|
||||
"""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", "/"),
|
||||
search=ctx.get("search", ""),
|
||||
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]
|
||||
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
|
||||
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,
|
||||
link_href=link_href,
|
||||
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:
|
||||
"""Post admin header row as sx wire format."""
|
||||
# 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)
|
||||
|
||||
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")
|
||||
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,
|
||||
link_href=admin_href,
|
||||
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.
|
||||
|
||||
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,
|
||||
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:
|
||||
"""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),
|
||||
)
|
||||
|
||||
@@ -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 = "",
|
||||
content: str = "", menu: str = "") -> str:
|
||||
"""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,
|
||||
filter=SxExpr(filter) if filter 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
|
||||
if not menu:
|
||||
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,
|
||||
filter=SxExpr(filter) if filter 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
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -780,7 +780,7 @@ async def sx_page(ctx: dict, page_sx: str, *,
|
||||
renders everything client-side. CSS rules are scanned from the sx
|
||||
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).
|
||||
"""
|
||||
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
|
||||
if body_scripts is not None:
|
||||
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 = """\
|
||||
|
||||
@@ -6,7 +6,7 @@ can coexist during incremental migration:
|
||||
|
||||
**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)::
|
||||
|
||||
@@ -22,10 +22,13 @@ from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import time
|
||||
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
|
||||
import os as _os
|
||||
if _os.environ.get("SX_USE_REF") == "1":
|
||||
@@ -33,6 +36,8 @@ if _os.environ.get("SX_USE_REF") == "1":
|
||||
else:
|
||||
from .html import render as html_render, _render_component
|
||||
|
||||
_logger = logging.getLogger("sx.bridge")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared component environment
|
||||
@@ -97,30 +102,193 @@ def _compute_component_hash() -> None:
|
||||
_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.
|
||||
|
||||
Skips boundary.sx — those are parsed separately by the boundary validator.
|
||||
Files starting with ``;; @client`` have their source stored for delivery
|
||||
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(
|
||||
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
|
||||
):
|
||||
if os.path.basename(filepath) == "boundary.sx":
|
||||
continue
|
||||
t0 = time.monotonic()
|
||||
|
||||
files = sorted(
|
||||
fp for fp in glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
|
||||
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:
|
||||
source = f.read()
|
||||
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
|
||||
exprs = parse_all(source)
|
||||
_CLIENT_LIBRARY_SOURCES.append(
|
||||
"\n".join(serialize(e) for e in exprs)
|
||||
)
|
||||
register_components(source)
|
||||
normalized = "\n".join(serialize(e) for e in exprs)
|
||||
new_client_sources.append(normalized)
|
||||
_CLIENT_LIBRARY_SOURCES.append(normalized)
|
||||
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:
|
||||
"""Re-read sx files if any have changed on disk. Called per-request in dev."""
|
||||
import logging
|
||||
import time
|
||||
_logger = logging.getLogger("sx.reload")
|
||||
reload_logger = logging.getLogger("sx.reload")
|
||||
|
||||
changed_files = []
|
||||
for directory in _watched_dirs:
|
||||
@@ -164,17 +330,22 @@ def reload_if_changed() -> None:
|
||||
changed_files.append(fp)
|
||||
if changed_files:
|
||||
for fp in changed_files:
|
||||
_logger.info("Changed: %s", fp)
|
||||
reload_logger.info("Changed: %s", fp)
|
||||
t0 = time.monotonic()
|
||||
_COMPONENT_ENV.clear()
|
||||
_CLIENT_LIBRARY_SOURCES.clear()
|
||||
_dirs_from_cache.clear()
|
||||
# Reload SX libraries first (e.g. z3.sx) so reader macros resolve
|
||||
for cb in _reload_callbacks:
|
||||
cb()
|
||||
# Load all directories with deferred finalization
|
||||
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()
|
||||
_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)
|
||||
|
||||
# Recompute render plans for all services that have pages
|
||||
@@ -182,7 +353,7 @@ def reload_if_changed() -> None:
|
||||
for svc in _PAGE_REGISTRY:
|
||||
t2 = time.monotonic()
|
||||
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:
|
||||
@@ -190,12 +361,17 @@ def load_service_components(service_dir: str, service_name: str | None = None) -
|
||||
|
||||
Components from ``{service_dir}/sx/`` and handlers from
|
||||
``{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")
|
||||
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)
|
||||
|
||||
_rebuild_closures()
|
||||
|
||||
# Load handler definitions if service_name is provided
|
||||
if 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)
|
||||
|
||||
|
||||
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
|
||||
shared environment.
|
||||
|
||||
Typically called at app startup::
|
||||
|
||||
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)))))
|
||||
''')
|
||||
When *_defer_postprocess* is True, skip deps/io_refs/hash computation.
|
||||
Call ``finalize_components()`` once after all files are loaded.
|
||||
"""
|
||||
from .ref.sx_ref import eval_expr as _raw_eval, trampoline as _trampoline
|
||||
_eval = lambda expr, env: _trampoline(_raw_eval(expr, env))
|
||||
@@ -242,8 +409,6 @@ def register_components(sx_source: str) -> None:
|
||||
_eval(expr, _COMPONENT_ENV)
|
||||
|
||||
# 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
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
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)
|
||||
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
|
||||
compute_all_deps(_COMPONENT_ENV)
|
||||
compute_all_io_refs(_COMPONENT_ENV, get_all_io_names())
|
||||
|
||||
_compute_component_hash()
|
||||
|
||||
|
||||
@@ -269,7 +441,7 @@ def sx(source: str, **kwargs: Any) -> str:
|
||||
Keyword arguments are merged into the evaluation environment,
|
||||
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 }}
|
||||
|
||||
This is a synchronous function — suitable for Jinja globals.
|
||||
|
||||
@@ -15,7 +15,7 @@ Usage::
|
||||
|
||||
# Error pages (no context needed)
|
||||
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",
|
||||
asset_url="/static",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""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" ...)
|
||||
This finds the balanced s-expression containing :data-suspense "{id}" and
|
||||
replaces it with the given replacement string.
|
||||
@@ -277,7 +277,7 @@ async def execute_page(
|
||||
if page_def.shell_expr is not None:
|
||||
shell_sx = await _eval_slot(page_def.shell_expr, env, ctx)
|
||||
# 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" ...)
|
||||
# We find the balanced s-expr containing :data-suspense "X" and replace it.
|
||||
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.
|
||||
# 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
|
||||
# (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.
|
||||
if page_def.shell_expr is not None:
|
||||
shell_content_sx = await _eval_slot(page_def.shell_expr, env, ctx)
|
||||
suspense_content_sx = shell_content_sx
|
||||
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),
|
||||
content=SxExpr(suspense_content_sx),
|
||||
)
|
||||
|
||||
# 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 = ""
|
||||
if layout is not None and hasattr(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 = ""
|
||||
if page_def.shell_expr is not None:
|
||||
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(
|
||||
tctx, initial_page_html, page_sx=page_sx_for_scan,
|
||||
)
|
||||
|
||||
# Capture component env + extras scanner while we still have context.
|
||||
# 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 quart import current_app as _ca
|
||||
_service = _ca.name
|
||||
|
||||
@@ -106,6 +106,14 @@ def _unescape_string(s: str) -> str:
|
||||
while i < len(s):
|
||||
if s[i] == "\\" and i + 1 < len(s):
|
||||
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))
|
||||
i += 2
|
||||
else:
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"string" (escape-html expr)
|
||||
"number" (escape-html (str expr))
|
||||
"raw-html" (raw-html-content expr)
|
||||
"spread" (do (emit! "element-attrs" (spread-attrs expr)) "")
|
||||
"symbol" (let ((val (async-eval expr env ctx)))
|
||||
(async-render val env ctx))
|
||||
"keyword" (escape-html (keyword-name expr))
|
||||
@@ -167,16 +168,25 @@
|
||||
(let ((class-val (dict-get attrs "class")))
|
||||
(when (and (not (nil? class-val)) (not (= class-val false)))
|
||||
(css-class-collect! (str class-val))))
|
||||
;; Build opening tag
|
||||
(let ((opening (str "<" tag (render-attrs attrs) ">")))
|
||||
(if (contains? VOID_ELEMENTS tag)
|
||||
opening
|
||||
(let ((token (if (or (= tag "svg") (= tag "math"))
|
||||
(svg-context-set! true)
|
||||
nil))
|
||||
(child-html (join "" (async-map-render children env ctx))))
|
||||
(when token (svg-context-reset! token))
|
||||
(str opening child-html "</" tag ">")))))))
|
||||
(if (contains? VOID_ELEMENTS tag)
|
||||
(str "<" tag (render-attrs attrs) ">")
|
||||
;; Provide scope for spread emit!
|
||||
(let ((token (if (or (= tag "svg") (= tag "math"))
|
||||
(svg-context-set! true)
|
||||
nil))
|
||||
(content-parts (list)))
|
||||
(provide-push! "element-attrs" nil)
|
||||
(for-each
|
||||
(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
|
||||
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params comp))
|
||||
;; Pre-render children to raw HTML
|
||||
(when (component-has-children? comp)
|
||||
(env-set! local "children"
|
||||
(make-raw-html
|
||||
(join "" (async-map-render children env ctx)))))
|
||||
(let ((parts (list)))
|
||||
(for-each
|
||||
(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)))))
|
||||
|
||||
|
||||
@@ -242,10 +256,14 @@
|
||||
(for-each
|
||||
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
|
||||
(component-params island))
|
||||
;; Pre-render children
|
||||
(when (component-has-children? island)
|
||||
(env-set! local "children"
|
||||
(make-raw-html
|
||||
(join "" (async-map-render children env ctx)))))
|
||||
(let ((parts (list)))
|
||||
(for-each
|
||||
(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))
|
||||
(state-json (serialize-island-state kwargs)))
|
||||
(str "<span data-sx-island=\"" (escape-attr island-name) "\""
|
||||
@@ -317,7 +335,7 @@
|
||||
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
|
||||
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
|
||||
"deftype" "defeffect"
|
||||
"map" "map-indexed" "filter" "for-each"))
|
||||
"map" "map-indexed" "filter" "for-each" "provide"))
|
||||
|
||||
(define async-render-form? :effects []
|
||||
(fn ((name :as string))
|
||||
@@ -343,11 +361,13 @@
|
||||
(async-render (nth expr 3) env ctx)
|
||||
"")))
|
||||
|
||||
;; when
|
||||
;; when — single body: pass through. Multi: join strings.
|
||||
(= name "when")
|
||||
(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
|
||||
(= name "cond")
|
||||
@@ -360,14 +380,18 @@
|
||||
(= name "case")
|
||||
(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*"))
|
||||
(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"))
|
||||
(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-form? name)
|
||||
@@ -377,15 +401,13 @@
|
||||
(= name "map")
|
||||
(let ((f (async-eval (nth expr 1) env ctx))
|
||||
(coll (async-eval (nth expr 2) env ctx)))
|
||||
(join ""
|
||||
(async-map-fn-render f coll env ctx)))
|
||||
(join "" (async-map-fn-render f coll env ctx)))
|
||||
|
||||
;; map-indexed
|
||||
(= name "map-indexed")
|
||||
(let ((f (async-eval (nth expr 1) env ctx))
|
||||
(coll (async-eval (nth expr 2) env ctx)))
|
||||
(join ""
|
||||
(async-map-indexed-fn-render f coll env ctx)))
|
||||
(join "" (async-map-indexed-fn-render f coll env ctx)))
|
||||
|
||||
;; filter — eval fully then render
|
||||
(= name "filter")
|
||||
@@ -395,8 +417,20 @@
|
||||
(= name "for-each")
|
||||
(let ((f (async-eval (nth expr 1) env ctx))
|
||||
(coll (async-eval (nth expr 2) env ctx)))
|
||||
(join ""
|
||||
(async-map-fn-render f coll env ctx)))
|
||||
(join "" (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
|
||||
:else
|
||||
@@ -545,32 +579,34 @@
|
||||
|
||||
(define-async async-aser :effects [render io]
|
||||
(fn (expr (env :as dict) ctx)
|
||||
(case (type-of expr)
|
||||
"number" expr
|
||||
"string" expr
|
||||
"boolean" expr
|
||||
"nil" nil
|
||||
|
||||
"symbol"
|
||||
(let ((name (symbol-name expr)))
|
||||
(cond
|
||||
(env-has? env name) (env-get env name)
|
||||
(primitive? name) (get-primitive name)
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (error (str "Undefined symbol: " name))))
|
||||
|
||||
"keyword" (keyword-name expr)
|
||||
|
||||
"dict" (async-aser-dict expr env ctx)
|
||||
|
||||
"list"
|
||||
(if (empty? expr)
|
||||
(list)
|
||||
(async-aser-list expr env ctx))
|
||||
|
||||
:else expr)))
|
||||
(let ((t (type-of expr))
|
||||
(result nil))
|
||||
(cond
|
||||
(= t "number") (set! result expr)
|
||||
(= t "string") (set! result expr)
|
||||
(= t "boolean") (set! result expr)
|
||||
(= t "nil") (set! result nil)
|
||||
(= t "symbol")
|
||||
(let ((name (symbol-name expr)))
|
||||
(set! result
|
||||
(cond
|
||||
(env-has? env name) (env-get env name)
|
||||
(primitive? name) (get-primitive name)
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (error (str "Undefined symbol: " name)))))
|
||||
(= t "keyword") (set! result (keyword-name expr))
|
||||
(= t "dict") (set! result (async-aser-dict expr env ctx))
|
||||
;; Spread — emit attrs to nearest element provider
|
||||
(= t "spread") (do (emit! "element-attrs" (spread-attrs expr))
|
||||
(set! result nil))
|
||||
(= t "list") (set! result (if (empty? expr) (list) (async-aser-list expr env ctx)))
|
||||
:else (set! result expr))
|
||||
;; Catch spread values from function calls and symbol lookups
|
||||
(if (spread? result)
|
||||
(do (emit! "element-attrs" (spread-attrs result)) nil)
|
||||
result))))
|
||||
|
||||
|
||||
(define-async async-aser-dict :effects [render io]
|
||||
@@ -806,9 +842,12 @@
|
||||
(let ((token (if (or (= name "svg") (= name "math"))
|
||||
(svg-context-set! true)
|
||||
nil))
|
||||
(parts (list name))
|
||||
(attr-parts (list))
|
||||
(child-parts (list))
|
||||
(skip false)
|
||||
(i 0))
|
||||
;; Provide scope for spread emit!
|
||||
(provide-push! "element-attrs" nil)
|
||||
(for-each
|
||||
(fn (arg)
|
||||
(if skip
|
||||
@@ -818,16 +857,16 @@
|
||||
(< (inc i) (len args)))
|
||||
(let ((val (async-aser (nth args (inc i)) env ctx)))
|
||||
(when (not (nil? val))
|
||||
(append! parts (str ":" (keyword-name arg)))
|
||||
(append! attr-parts (str ":" (keyword-name arg)))
|
||||
(if (= (type-of val) "list")
|
||||
(let ((live (filter (fn (v) (not (nil? v))) val)))
|
||||
(if (empty? live)
|
||||
(append! parts "nil")
|
||||
(append! attr-parts "nil")
|
||||
(let ((items (map serialize live)))
|
||||
(if (some (fn (v) (sx-expr? v)) live)
|
||||
(append! parts (str "(<> " (join " " items) ")"))
|
||||
(append! parts (str "(list " (join " " items) ")"))))))
|
||||
(append! parts (serialize val))))
|
||||
(append! attr-parts (str "(<> " (join " " items) ")"))
|
||||
(append! attr-parts (str "(list " (join " " items) ")"))))))
|
||||
(append! attr-parts (serialize val))))
|
||||
(set! skip true)
|
||||
(set! i (inc i)))
|
||||
(let ((result (async-aser arg env ctx)))
|
||||
@@ -836,13 +875,25 @@
|
||||
(for-each
|
||||
(fn (item)
|
||||
(when (not (nil? item))
|
||||
(append! parts (serialize item))))
|
||||
(append! child-parts (serialize item))))
|
||||
result)
|
||||
(append! parts (serialize result))))
|
||||
(append! child-parts (serialize result))))
|
||||
(set! i (inc i))))))
|
||||
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))
|
||||
(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"
|
||||
"defhandler" "defpage" "defquery" "defaction"
|
||||
"begin" "do" "quote" "->" "set!" "defisland"
|
||||
"deftype" "defeffect"))
|
||||
"deftype" "defeffect" "provide"))
|
||||
|
||||
(define ASYNC_ASER_HO_NAMES
|
||||
(list "map" "map-indexed" "filter" "for-each"))
|
||||
@@ -993,6 +1044,17 @@
|
||||
(= name "deftype") (= name "defeffect"))
|
||||
(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
|
||||
:else
|
||||
(async-eval expr env ctx)))))
|
||||
@@ -1250,6 +1312,14 @@
|
||||
;; (svg-context-reset! token) — reset SVG context
|
||||
;; (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:
|
||||
;; (is-raw-html? x) — check if raw HTML marker
|
||||
;; (make-raw-html s) — wrap string as raw HTML
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
;; Pre-rendered DOM node → pass through
|
||||
"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" (create-fragment)
|
||||
|
||||
@@ -157,7 +160,11 @@
|
||||
;; Data list
|
||||
:else
|
||||
(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)))))
|
||||
|
||||
|
||||
@@ -173,6 +180,9 @@
|
||||
:else 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
|
||||
(reduce
|
||||
(fn (state arg)
|
||||
@@ -221,14 +231,46 @@
|
||||
(dom-set-attr el attr-name (str attr-val)))))
|
||||
(assoc state "skip" true "i" (inc (get state "i"))))
|
||||
|
||||
;; Positional arg → child
|
||||
;; Positional arg → child (or spread → merge attrs onto element)
|
||||
(do
|
||||
(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"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
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)))
|
||||
|
||||
|
||||
@@ -269,10 +311,14 @@
|
||||
(component-params comp))
|
||||
|
||||
;; 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)
|
||||
(let ((child-frag (create-fragment)))
|
||||
(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)
|
||||
(env-set! local "children" child-frag)))
|
||||
|
||||
@@ -287,7 +333,10 @@
|
||||
(fn ((args :as list) (env :as dict) (ns :as string))
|
||||
(let ((frag (create-fragment)))
|
||||
(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)
|
||||
frag)))
|
||||
|
||||
@@ -332,7 +381,7 @@
|
||||
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
|
||||
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
|
||||
"map" "map-indexed" "filter" "for-each" "portal"
|
||||
"error-boundary"))
|
||||
"error-boundary" "provide"))
|
||||
|
||||
(define render-dom-form? :effects []
|
||||
(fn ((name :as string))
|
||||
@@ -368,16 +417,19 @@
|
||||
(dom-insert-after marker result))
|
||||
;; Marker not yet in DOM (first run) — just save result
|
||||
(set! initial-result result)))))
|
||||
;; Return fragment: marker + initial render result
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result
|
||||
(set! current-nodes
|
||||
(if (dom-is-fragment? initial-result)
|
||||
(dom-child-nodes initial-result)
|
||||
(list initial-result)))
|
||||
(dom-append frag initial-result))
|
||||
frag))
|
||||
;; Spread pass-through: spreads aren't DOM nodes, can't live
|
||||
;; in fragments. Return directly so parent element merges attrs.
|
||||
(if (spread? initial-result)
|
||||
initial-result
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result
|
||||
(set! current-nodes
|
||||
(if (dom-is-fragment? initial-result)
|
||||
(dom-child-nodes initial-result)
|
||||
(list initial-result)))
|
||||
(dom-append frag initial-result))
|
||||
frag)))
|
||||
;; Static if
|
||||
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
|
||||
(if cond-val
|
||||
@@ -415,10 +467,13 @@
|
||||
(range 2 (len expr)))
|
||||
(set! current-nodes (dom-child-nodes frag))
|
||||
(set! initial-result frag))))))
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result (dom-append frag initial-result))
|
||||
frag))
|
||||
;; Spread pass-through
|
||||
(if (spread? initial-result)
|
||||
initial-result
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result (dom-append frag initial-result))
|
||||
frag)))
|
||||
;; Static when
|
||||
(if (not (trampoline (eval-expr (nth expr 1) env)))
|
||||
(create-fragment)
|
||||
@@ -457,10 +512,13 @@
|
||||
(dom-child-nodes result)
|
||||
(list result)))
|
||||
(set! initial-result result)))))))
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result (dom-append frag initial-result))
|
||||
frag))
|
||||
;; Spread pass-through
|
||||
(if (spread? initial-result)
|
||||
initial-result
|
||||
(let ((frag (create-fragment)))
|
||||
(dom-append frag marker)
|
||||
(when initial-result (dom-append frag initial-result))
|
||||
frag)))
|
||||
;; Static cond
|
||||
(let ((branch (eval-cond (rest expr) env)))
|
||||
(if branch
|
||||
@@ -471,24 +529,32 @@
|
||||
(= name "case")
|
||||
(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*"))
|
||||
(let ((local (process-bindings (nth expr 1) env))
|
||||
(frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(dom-append frag (render-to-dom (nth expr i) local ns)))
|
||||
(range 2 (len expr)))
|
||||
frag)
|
||||
(let ((local (process-bindings (nth expr 1) env)))
|
||||
(if (= (len expr) 3)
|
||||
(render-to-dom (nth expr 2) local ns)
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(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"))
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(dom-append frag (render-to-dom (nth expr i) env ns)))
|
||||
(range 1 (len expr)))
|
||||
frag)
|
||||
(if (= (len expr) 2)
|
||||
(render-to-dom (nth expr 1) env ns)
|
||||
(let ((frag (create-fragment)))
|
||||
(for-each
|
||||
(fn (i)
|
||||
(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-form? name)
|
||||
@@ -571,6 +637,19 @@
|
||||
coll)
|
||||
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
|
||||
:else
|
||||
(render-to-dom (trampoline (eval-expr expr env)) env ns))))
|
||||
@@ -799,6 +878,64 @@
|
||||
:else
|
||||
(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
|
||||
;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island.
|
||||
(define reactive-fragment :effects [render mutation]
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"keyword" (escape-html (keyword-name expr))
|
||||
;; Raw HTML passthrough
|
||||
"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
|
||||
:else (render-value-to-html (trampoline (eval-expr expr env)) env))))
|
||||
|
||||
@@ -42,6 +44,7 @@
|
||||
"boolean" (if val "true" "false")
|
||||
"list" (render-list-to-html val env)
|
||||
"raw-html" (raw-html-content val)
|
||||
"spread" (do (emit! "element-attrs" (spread-attrs val)) "")
|
||||
:else (escape-html (str val)))))
|
||||
|
||||
|
||||
@@ -53,7 +56,7 @@
|
||||
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
|
||||
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
|
||||
"deftype" "defeffect"
|
||||
"map" "map-indexed" "filter" "for-each"))
|
||||
"map" "map-indexed" "filter" "for-each" "provide"))
|
||||
|
||||
(define render-html-form? :effects []
|
||||
(fn ((name :as string))
|
||||
@@ -147,14 +150,14 @@
|
||||
(render-to-html (nth expr 3) env)
|
||||
"")))
|
||||
|
||||
;; when
|
||||
;; when — single body: pass through. Multi: join strings.
|
||||
(= name "when")
|
||||
(if (not (trampoline (eval-expr (nth expr 1) env)))
|
||||
""
|
||||
(join ""
|
||||
(map
|
||||
(fn (i) (render-to-html (nth expr i) env))
|
||||
(range 2 (len expr)))))
|
||||
(if (= (len expr) 3)
|
||||
(render-to-html (nth expr 2) env)
|
||||
(join "" (map (fn (i) (render-to-html (nth expr i) env))
|
||||
(range 2 (len expr))))))
|
||||
|
||||
;; cond
|
||||
(= name "cond")
|
||||
@@ -167,20 +170,20 @@
|
||||
(= name "case")
|
||||
(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*"))
|
||||
(let ((local (process-bindings (nth expr 1) env)))
|
||||
(join ""
|
||||
(map
|
||||
(fn (i) (render-to-html (nth expr i) local))
|
||||
(range 2 (len expr)))))
|
||||
(if (= (len expr) 3)
|
||||
(render-to-html (nth expr 2) local)
|
||||
(join "" (map (fn (i) (render-to-html (nth expr i) local))
|
||||
(range 2 (len expr))))))
|
||||
|
||||
;; begin / do
|
||||
;; begin / do — single body: pass through. Multi: join strings.
|
||||
(or (= name "begin") (= name "do"))
|
||||
(join ""
|
||||
(map
|
||||
(fn (i) (render-to-html (nth expr i) env))
|
||||
(range 1 (len expr))))
|
||||
(if (= (len expr) 2)
|
||||
(render-to-html (nth expr 1) env)
|
||||
(join "" (map (fn (i) (render-to-html (nth expr i) env))
|
||||
(range 1 (len expr)))))
|
||||
|
||||
;; Definition forms — eval for side effects
|
||||
(definition-form? name)
|
||||
@@ -226,6 +229,20 @@
|
||||
(render-to-html (apply f (list item)) env)))
|
||||
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
|
||||
:else
|
||||
(render-value-to-html (trampoline (eval-expr expr env)) env))))
|
||||
@@ -283,8 +300,7 @@
|
||||
;; If component accepts children, pre-render them to raw HTML
|
||||
(when (component-has-children? comp)
|
||||
(env-set! local "children"
|
||||
(make-raw-html
|
||||
(join "" (map (fn (c) (render-to-html c env)) children)))))
|
||||
(make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
|
||||
(render-to-html (component-body comp) local)))))
|
||||
|
||||
|
||||
@@ -294,13 +310,19 @@
|
||||
(attrs (first parsed))
|
||||
(children (nth parsed 1))
|
||||
(is-void (contains? VOID_ELEMENTS tag)))
|
||||
(str "<" tag
|
||||
(render-attrs attrs)
|
||||
(if is-void
|
||||
" />"
|
||||
(str ">"
|
||||
(join "" (map (fn (c) (render-to-html c env)) children))
|
||||
"</" tag ">"))))))
|
||||
(if is-void
|
||||
(str "<" tag (render-attrs attrs) " />")
|
||||
;; Provide scope for spread emit!
|
||||
(do
|
||||
(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 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"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
(str "<" lake-tag " data-sx-lake=\"" (escape-attr (or lake-id "")) "\">"
|
||||
(join "" (map (fn (c) (render-to-html c env)) children))
|
||||
"</" lake-tag ">"))))
|
||||
;; Provide scope for spread emit!
|
||||
(let ((lake-attrs (dict "data-sx-lake" (or lake-id ""))))
|
||||
(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"))))))))
|
||||
(dict "i" 0 "skip" false)
|
||||
args)
|
||||
(str "<" marsh-tag " data-sx-marsh=\"" (escape-attr (or marsh-id "")) "\">"
|
||||
(join "" (map (fn (c) (render-to-html c env)) children))
|
||||
"</" marsh-tag ">"))))
|
||||
;; Provide scope for spread emit!
|
||||
(let ((marsh-attrs (dict "data-sx-marsh" (or marsh-id ""))))
|
||||
(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
|
||||
(when (component-has-children? island)
|
||||
(env-set! local "children"
|
||||
(make-raw-html
|
||||
(join "" (map (fn (c) (render-to-html c env)) children)))))
|
||||
(make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
|
||||
|
||||
;; Render the island body as HTML
|
||||
(let ((body-html (render-to-html (component-body island) local))
|
||||
|
||||
@@ -25,30 +25,38 @@
|
||||
;; Evaluate for SX wire format — serialize rendering forms,
|
||||
;; evaluate control flow and function calls.
|
||||
(set-render-active! true)
|
||||
(case (type-of expr)
|
||||
"number" expr
|
||||
"string" expr
|
||||
"boolean" expr
|
||||
"nil" nil
|
||||
(let ((result
|
||||
(case (type-of expr)
|
||||
"number" expr
|
||||
"string" expr
|
||||
"boolean" expr
|
||||
"nil" nil
|
||||
|
||||
"symbol"
|
||||
(let ((name (symbol-name expr)))
|
||||
(cond
|
||||
(env-has? env name) (env-get env name)
|
||||
(primitive? name) (get-primitive name)
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (error (str "Undefined symbol: " name))))
|
||||
"symbol"
|
||||
(let ((name (symbol-name expr)))
|
||||
(cond
|
||||
(env-has? env name) (env-get env name)
|
||||
(primitive? name) (get-primitive name)
|
||||
(= name "true") true
|
||||
(= name "false") false
|
||||
(= name "nil") nil
|
||||
:else (error (str "Undefined symbol: " name))))
|
||||
|
||||
"keyword" (keyword-name expr)
|
||||
"keyword" (keyword-name expr)
|
||||
|
||||
"list"
|
||||
(if (empty? expr)
|
||||
(list)
|
||||
(aser-list expr env))
|
||||
"list"
|
||||
(if (empty? expr)
|
||||
(list)
|
||||
(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]
|
||||
@@ -130,9 +138,13 @@
|
||||
;; Serialize (name :key val child ...) — evaluate args but keep as sx
|
||||
;; Uses for-each + mutable state (not reduce) so bootstrapper emits for-loops
|
||||
;; 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)
|
||||
(i 0))
|
||||
;; Provide scope for spread emit!
|
||||
(provide-push! "element-attrs" nil)
|
||||
(for-each
|
||||
(fn (arg)
|
||||
(if skip
|
||||
@@ -142,8 +154,8 @@
|
||||
(< (inc i) (len args)))
|
||||
(let ((val (aser (nth args (inc i)) env)))
|
||||
(when (not (nil? val))
|
||||
(append! parts (str ":" (keyword-name arg)))
|
||||
(append! parts (serialize val)))
|
||||
(append! attr-parts (str ":" (keyword-name arg)))
|
||||
(append! attr-parts (serialize val)))
|
||||
(set! skip true)
|
||||
(set! i (inc i)))
|
||||
(let ((val (aser arg env)))
|
||||
@@ -152,12 +164,24 @@
|
||||
(for-each
|
||||
(fn (item)
|
||||
(when (not (nil? item))
|
||||
(append! parts (serialize item))))
|
||||
(append! child-parts (serialize item))))
|
||||
val)
|
||||
(append! parts (serialize val))))
|
||||
(append! child-parts (serialize val))))
|
||||
(set! i (inc i))))))
|
||||
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"
|
||||
"begin" "do" "quote" "quasiquote"
|
||||
"->" "set!" "letrec" "dynamic-wind" "defisland"
|
||||
"deftype" "defeffect"))
|
||||
"deftype" "defeffect" "provide"))
|
||||
|
||||
(define HO_FORM_NAMES
|
||||
(list "map" "map-indexed" "filter" "reduce"
|
||||
@@ -309,6 +333,17 @@
|
||||
(= name "deftype") (= name "defeffect"))
|
||||
(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
|
||||
:else
|
||||
(trampoline (eval-expr expr env))))))
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
;; Process sx- attributes, hydrate data-sx and islands
|
||||
(process-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)
|
||||
(sx-hydrate-elements el)
|
||||
(sx-hydrate-islands el)
|
||||
(flush-cssx-to-dom)
|
||||
(dom-dispatch el "sx:resolved" {:id id})))
|
||||
(log-warn (str "resolveSuspense: no element for id=" id))))))
|
||||
|
||||
@@ -415,6 +417,32 @@
|
||||
(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
|
||||
;; --------------------------------------------------------------------------
|
||||
@@ -436,6 +464,7 @@
|
||||
(process-sx-scripts nil)
|
||||
(sx-hydrate-elements nil)
|
||||
(sx-hydrate-islands nil)
|
||||
(flush-cssx-to-dom)
|
||||
(process-elements nil))))
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user