Files
mono/blog/sx/settings.sx
giles 64aa417d63 Replace JSON sx-headers with SX dict expressions, fix blog like component
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>
2026-03-05 09:25:28 +00:00

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."))))))