All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
sx-headers attributes now use native SX dict format {:key val} instead of
JSON strings. Eliminates manual JSON string construction in both .sx files
and Python callers.
- sx.js: parse sx-headers/sx-vals as SX dict ({: prefix) with JSON fallback,
add _serializeDict for dict→attribute serialization, fix verbInfo scope in
_doFetch error handler
- html.py: serialize dict attribute values via SX serialize() not str()
- All .sx files: {:X-CSRFToken csrf} replaces (str "{\"X-CSRFToken\": ...}")
- All Python callers: {"X-CSRFToken": csrf} dict replaces f-string JSON
- Blog like: extract ~blog-like-toggle, fix POST returning wrong component,
fix emoji escapes in .sx (parser has no \U support), fix card :hx-headers
keyword mismatch, wrap sx_content in SxExpr for evaluation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
17 KiB
Plaintext
293 lines
17 KiB
Plaintext
;; Blog settings panel components (features, markets, associated entries)
|
|
|
|
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
|
|
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
|
|
:sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
|
|
(label :class "flex items-center gap-3 cursor-pointer"
|
|
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
|
|
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
|
:_ hs-trigger)
|
|
(span :class "text-sm text-stone-700"
|
|
(i :class "fa fa-calendar text-blue-600 mr-1")
|
|
" Calendar \u2014 enable event booking on this page"))
|
|
(label :class "flex items-center gap-3 cursor-pointer"
|
|
(input :type "checkbox" :name "market" :value "true" :checked market-checked
|
|
:class "h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"
|
|
:_ hs-trigger)
|
|
(span :class "text-sm text-stone-700"
|
|
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
|
" Market \u2014 enable product catalog on this page"))))
|
|
|
|
(defcomp ~blog-sumup-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
|
|
:placeholder placeholder :sumup-configured sumup-configured
|
|
:checkout-prefix checkout-prefix :panel-id "features-panel")))
|
|
|
|
(defcomp ~blog-features-panel (&key form sumup)
|
|
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
|
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
|
|
form sumup))
|
|
|
|
;; Markets panel
|
|
|
|
(defcomp ~blog-market-item (&key name slug delete-url confirm-text)
|
|
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
|
|
(div (span :class "font-medium" name)
|
|
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
|
|
(button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML"
|
|
:sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
|
|
|
|
(defcomp ~blog-markets-list (&key items)
|
|
(ul :class "space-y-2 mb-4" items))
|
|
|
|
(defcomp ~blog-markets-empty ()
|
|
(p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
|
|
|
|
(defcomp ~blog-markets-panel (&key list create-url)
|
|
(div :id "markets-panel"
|
|
(h3 :class "text-lg font-semibold mb-3" "Markets")
|
|
list
|
|
(form :sx-post create-url :sx-target "#markets-panel" :sx-swap "outerHTML" :class "flex gap-2"
|
|
(input :type "text" :name "name" :placeholder "Market name" :required ""
|
|
:class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")
|
|
(button :type "submit"
|
|
:class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create"))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Data-driven composition defcomps — replace Python render_* functions
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
;; Features panel composition — replaces render_features_panel
|
|
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked
|
|
show-sumup sumup-url merchant-code placeholder
|
|
sumup-configured checkout-prefix)
|
|
(~blog-features-panel
|
|
:form (~blog-features-form
|
|
:features-url features-url
|
|
:calendar-checked calendar-checked
|
|
:market-checked market-checked
|
|
:hs-trigger "on change trigger submit on closest <form/>")
|
|
:sumup (when show-sumup
|
|
(~blog-sumup-form
|
|
:sumup-url sumup-url
|
|
:merchant-code merchant-code
|
|
:placeholder placeholder
|
|
:sumup-configured sumup-configured
|
|
:checkout-prefix checkout-prefix))))
|
|
|
|
;; Markets panel composition — replaces render_markets_panel
|
|
(defcomp ~blog-markets-panel-content (&key markets create-url)
|
|
(~blog-markets-panel
|
|
:list (if (empty? (or markets (list)))
|
|
(~blog-markets-empty)
|
|
(~blog-markets-list
|
|
:items (map (lambda (m)
|
|
(~blog-market-item
|
|
:name (get m "name")
|
|
:slug (get m "slug")
|
|
:delete-url (get m "delete_url")
|
|
:confirm-text (str "Delete market '" (get m "name") "'?")))
|
|
(or markets (list)))))
|
|
:create-url create-url))
|
|
|
|
;; Associated entries
|
|
|
|
(defcomp ~blog-entry-image (&key src title)
|
|
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
|
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
|
|
|
(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img name date-str)
|
|
(button :type "button"
|
|
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
|
|
:data-confirm "" :data-confirm-title "Remove entry?"
|
|
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
|
:data-confirm-confirm-text "Yes, remove it"
|
|
:data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"
|
|
:sx-post toggle-url :sx-trigger "confirmed"
|
|
:sx-target "#associated-entries-list" :sx-swap "outerHTML"
|
|
:sx-headers hx-headers
|
|
:_ "on htmx:afterRequest trigger entryToggled on body"
|
|
(div :class "flex items-center justify-between gap-3"
|
|
img
|
|
(div :class "flex-1"
|
|
(div :class "font-medium text-sm" name)
|
|
(div :class "text-xs text-stone-600 mt-1" date-str))
|
|
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
|
|
|
|
(defcomp ~blog-associated-entries-content (&key items)
|
|
(div :class "space-y-1" items))
|
|
|
|
(defcomp ~blog-associated-entries-empty ()
|
|
(div :class "text-sm text-stone-400"
|
|
"No entries associated yet. Browse calendars below to add entries."))
|
|
|
|
(defcomp ~blog-associated-entries-panel (&key content)
|
|
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
|
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
|
content))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Associated entries composition — replaces _render_associated_entries
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~blog-associated-entries-from-data (&key entries csrf)
|
|
(~blog-associated-entries-panel
|
|
:content (if (empty? (or entries (list)))
|
|
(~blog-associated-entries-empty)
|
|
(~blog-associated-entries-content
|
|
:items (map (lambda (e)
|
|
(~blog-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"))
|
|
:name (get e "name")
|
|
:date-str (get e "date_str")))
|
|
(or entries (list)))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Entries browser composition — replaces _h_post_entries_content
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~blog-calendar-browser-item (&key name title image view-url)
|
|
(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
|
|
(img :src image :alt title :class "w-12 h-12 rounded object-cover flex-shrink-0")
|
|
(div :class "w-12 h-12 rounded bg-stone-200 flex-shrink-0"))
|
|
(div :class "flex-1"
|
|
(div :class "font-semibold flex items-center gap-2"
|
|
(i :class "fa fa-calendar text-stone-500") " " name)
|
|
(div :class "text-sm text-stone-600" title)))
|
|
(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)
|
|
(div :id "post-entries-content" :class "space-y-6 p-4"
|
|
entries-panel
|
|
(div :class "space-y-3"
|
|
(h3 :class "text-lg font-semibold" "Browse Calendars")
|
|
(if (empty? (or calendars (list)))
|
|
(div :class "text-sm text-stone-400" "No calendars found.")
|
|
(map (lambda (cal)
|
|
(~blog-calendar-browser-item
|
|
:name (get cal "name")
|
|
:title (get cal "title")
|
|
:image (get cal "image")
|
|
:view-url (get cal "view_url")))
|
|
(or calendars (list)))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Post settings form composition — replaces _h_post_settings_content
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~blog-settings-field-label (&key text field-for)
|
|
(label :for field-for
|
|
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
|
|
|
|
(defcomp ~blog-settings-section (&key title content is-open)
|
|
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
|
|
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
|
|
title)
|
|
(div :class "px-[16px] py-[12px] space-y-[12px]" content)))
|
|
|
|
(defcomp ~blog-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
|
|
og-title og-description og-image
|
|
twitter-title twitter-description twitter-image
|
|
custom-template)
|
|
(let* ((input-cls "w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] bg-white text-stone-700 placeholder:text-stone-300 focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300")
|
|
(textarea-cls (str input-cls " resize-y"))
|
|
(slug-placeholder (if is-page "page-slug" "post-slug"))
|
|
(tmpl-placeholder (if is-page "custom-page.hbs" "custom-post.hbs"))
|
|
(featured-label (if is-page "Featured page" "Featured post")))
|
|
(form :method "post" :class "max-w-[640px] mx-auto pb-[48px] px-[16px]"
|
|
(input :type "hidden" :name "csrf_token" :value csrf)
|
|
(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
|
|
(<>
|
|
(div (~blog-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")
|
|
(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")
|
|
(select :name "visibility" :id "settings-visibility" :class input-cls
|
|
(option :value "public" :selected (= visibility "public") "Public")
|
|
(option :value "members" :selected (= visibility "members") "Members")
|
|
(option :value "paid" :selected (= visibility "paid") "Paid")))
|
|
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
|
|
(input :type "checkbox" :name "email_only" :id "settings-email_only" :checked email-only
|
|
: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")
|
|
(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")
|
|
(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
|
|
(<>
|
|
(div (~blog-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")
|
|
(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")
|
|
(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
|
|
(<>
|
|
(div (~blog-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")
|
|
(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")
|
|
(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
|
|
(<>
|
|
(div (~blog-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")
|
|
(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")
|
|
(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")
|
|
(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"
|
|
(button :type "submit"
|
|
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
|
|
"Save settings")
|
|
(when save-success
|
|
(span :class "text-[14px] text-green-600" "Saved."))))))
|