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