diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx index d227407..ce1a8ed 100644 --- a/blog/sx/admin.sx +++ b/blog/sx/admin.sx @@ -143,6 +143,80 @@ (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 + :rows (<> (map (lambda (s) + (let* ((s-id (get s "id")) + (s-name (get s "name")) + (s-uid (get s "user_id")) + (s-vis (get s "visibility")) + (owner (if (= s-uid user-id) "You" (str "User #" s-uid))) + (badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700")) + (extra (<> + (when is-admin + (~blog-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")) + :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" + :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 + :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 + :rows (<> (map (lambda (item) + (let* ((img (~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 + :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") + :confirm-text (str "Remove " (get item "label") " from the menu?") + :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))) + (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) + :groups (if (empty? (or groups (list))) + (~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm") + (~blog-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") + :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 + :heading (str "Unassigned Tags (" (len unassigned-tags) ")") + :spans (<> (map (lambda (t) + (~blog-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) + (<> (map (lambda (t) + (~blog-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"))) + :name (get t "name"))) + (or tags (list))))) + ;; Preview panel components (defcomp ~blog-preview-panel (&key sections) diff --git a/blog/sx/cards.sx b/blog/sx/cards.sx index 2fde9ee..53460b3 100644 --- a/blog/sx/cards.sx +++ b/blog/sx/cards.sx @@ -106,6 +106,43 @@ (ul :class "flex flex-wrap gap-2 text-sm" (map (lambda (a) (~blog-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 view sentinel) + (<> + (map (lambda (p) + (if (= view "tile") + (~blog-card-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 + :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") + :publish-requested (get p "publish_requested") + :status-timestamp (get p "status_timestamp") + :has-like (get p "has_like") :liked (get p "liked") + :like-url (get p "like_url") :csrf-token (get p "csrf_token") + :tags (get p "tags") :authors (get p "authors") + :widget (when (get p "widget") (~rich-text :html (get p "widget")))))) + (or posts (list))) + sentinel)) + +;; Data-driven page cards list (replaces Python _page_cards_sx loop) +(defcomp ~page-cards-from-data (&key pages sentinel) + (<> + (map (lambda (pg) + (~blog-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") + :pub-timestamp (get pg "pub_timestamp") + :feature-image (get pg "feature_image") :excerpt (get pg "excerpt"))) + (or pages (list))) + sentinel)) + (defcomp ~blog-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" diff --git a/blog/sx/filters.sx b/blog/sx/filters.sx index 2198389..4332ea3 100644 --- a/blog/sx/filters.sx +++ b/blog/sx/filters.sx @@ -63,3 +63,39 @@ (defcomp ~blog-filter-summary (&key text) (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) + (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 + :items (<> + (~blog-filter-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 + :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) + (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 + :items (<> + (~blog-filter-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 + :icon icon :name (get a "name") :count (get a "count")))) + (or authors (list))))))) diff --git a/blog/sx/menu_items.sx b/blog/sx/menu_items.sx index d9fb57d..2cbc6f8 100644 --- a/blog/sx/menu_items.sx +++ b/blog/sx/menu_items.sx @@ -24,3 +24,37 @@ (defcomp ~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) + (if (and (not pages) query) + (~page-search-empty :query query) + (when pages + (~page-search-results + :items (<> (map (lambda (p) + (~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)))))) + +;; 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) + (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 + :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") + :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") + :nav-cls nav-cls :img img :label (get item "label")) + (~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))) diff --git a/federation/sx/profile.sx b/federation/sx/profile.sx index 16108a0..7bfc8ab 100644 --- a/federation/sx/profile.sx +++ b/federation/sx/profile.sx @@ -90,3 +90,16 @@ (url-for "social.actor_timeline_page" :id (get remote-actor "id") :before (get (last items) "before_cursor"))))))) + +;; Data-driven activities list (replaces Python loop in render_profile_page) +(defcomp ~federation-activities-from-data (&key activities) + (if (empty? (or activities (list))) + (~federation-activities-empty) + (~federation-activities-list + :items (<> (map (lambda (a) + (~federation-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"))))) + activities))))) diff --git a/federation/sx/search.sx b/federation/sx/search.sx index 9ead36d..1dd2b3c 100644 --- a/federation/sx/search.sx +++ b/federation/sx/search.sx @@ -40,6 +40,47 @@ summary) button)) +;; Data-driven actor card (replaces Python _actor_card_sx loop) +(defcomp ~federation-actor-card-from-data (&key d has-actor csrf follow-url unfollow-url list-type) + (let* ((icon-url (get d "icon_url")) + (display-name (get d "display_name")) + (username (get d "username")) + (domain (get d "domain")) + (actor-url (get d "actor_url")) + (safe-id (get d "safe_id")) + (initial (or (get d "initial") "?")) + (avatar (~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))) + (summary-sx (when (get d "summary") + (~federation-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 + :label (if (= list-type "followers") "Follow Back" "Follow")))))) + (~federation-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 next-url has-actor csrf + follow-url unfollow-url list-type) + (<> + (map (lambda (d) + (~federation-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)))) + (defcomp ~federation-search-info (&key cls text) (p :class cls text)) diff --git a/federation/sx/social.sx b/federation/sx/social.sx index 1f3fa8c..bd5054f 100644 --- a/federation/sx/social.sx +++ b/federation/sx/social.sx @@ -90,6 +90,65 @@ compose) (div :id "timeline" timeline)) +;; --- Data-driven post card (replaces Python _post_card_sx loop) --- + +(defcomp ~federation-post-card-from-data (&key d has-actor csrf + like-url unlike-url + boost-url unboost-url) + (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 + :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))) + (content-sx (if (get d "summary") + (~federation-content :content (get d "content") :summary (get d "summary")) + (~federation-content :content (get d "content")))) + (original (when (get d "original_url") + (~federation-original-link :url (get d "original_url")))) + (safe-id (get d "safe_id")) + (interactions (when has-actor + (let* ((oid (get d "object_id")) + (ainbox (get d "author_inbox")) + (target (str "#interactions-" safe-id)) + (liked (get d "liked_by_me")) + (boosted-me (get d "boosted_by_me")) + (l-action (if liked unlike-url like-url)) + (l-cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500"))) + (l-icon (if liked "\u2665" "\u2661")) + (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 + :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 + :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 + :boost boost :avatar avatar + :actor-name actor-name :actor-username (get d "actor_username") + :domain (get d "domain") :time (get d "time") + :content content-sx :original original + :interactions interactions))) + +;; Data-driven timeline items (replaces Python _timeline_items_sx loop) +(defcomp ~federation-timeline-items-from-data (&key items next-url has-actor csrf + like-url unlike-url boost-url unboost-url) + (<> + (map (lambda (d) + (~federation-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)))) + ;; --- Compose --- (defcomp ~federation-compose-reply (&key reply-to) diff --git a/orders/sx/checkout.sx b/orders/sx/checkout.sx index 7c6eeeb..8dfa2d9 100644 --- a/orders/sx/checkout.sx +++ b/orders/sx/checkout.sx @@ -47,6 +47,17 @@ (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) + (~checkout-return-tickets + :items (<> (map (lambda (tk) + (~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) (div :class "max-w-full px-1 py-1" (when summary diff --git a/shared/sx/templates/orders.sx b/shared/sx/templates/orders.sx index a05f97b..4d11f4f 100644 --- a/shared/sx/templates/orders.sx +++ b/shared/sx/templates/orders.sx @@ -120,6 +120,57 @@ (<> auth (~header-child-sx :id "auth-header-child" :inner (<> orders (~header-child-sx :id "orders-header-child" :inner order)))))) +;; --------------------------------------------------------------------------- +;; Data-driven order rows (replaces Python loop) +;; --------------------------------------------------------------------------- + +(defcomp ~order-rows-from-data (&key orders page total-pages next-url) + (<> + (map (lambda (o) + (<> + (~order-row-desktop :oid (get o "oid") :created (get o "created") + :desc (get o "desc") :total (get o "total") + :pill (get o "pill_desktop") :status (get o "status") :url (get o "url")) + (~order-row-mobile :oid (get o "oid") :created (get o "created") + :total (get o "total") :pill (get o "pill_mobile") + :status (get o "status") :url (get o "url")))) + (or orders (list))) + (if next-url + (~infinite-scroll :url next-url :page page :total-pages total-pages + :id-prefix "orders" :colspan 5) + (~order-end-row)))) + +;; --------------------------------------------------------------------------- +;; Data-driven order items (replaces Python loop) +;; --------------------------------------------------------------------------- + +(defcomp ~order-items-from-data (&key items) + (~order-items-panel + :items (<> (map (lambda (item) + (let* ((img (if (get item "product_image") + (~order-item-image :src (get item "product_image") :alt (or (get item "product_title") "Product image")) + (~order-item-no-image)))) + (~order-item-row + :href (get item "href") :img img + :title (or (get item "product_title") "Unknown product") + :pid (str "Product ID: " (get item "product_id")) + :qty (str "Qty: " (get item "quantity")) + :price (get item "price")))) + (or items (list)))))) + +;; --------------------------------------------------------------------------- +;; Data-driven calendar entries (replaces Python loop) +;; --------------------------------------------------------------------------- + +(defcomp ~order-calendar-from-data (&key entries) + (~order-calendar-section + :items (<> (map (lambda (e) + (~order-calendar-entry + :name (get e "name") :pill (get e "pill") + :status (get e "status") :date-str (get e "date_str") + :cost (get e "cost"))) + (or entries (list)))))) + ;; --------------------------------------------------------------------------- ;; Checkout error screens ;; ---------------------------------------------------------------------------