Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
# Conflicts: # blog/bp/post/admin/routes.py # events/sxc/pages/calendar.py # events/sxc/pages/entries.py # events/sxc/pages/slots.py # events/sxc/pages/tickets.py
594 lines
30 KiB
Plaintext
594 lines
30 KiB
Plaintext
;; Blog admin panel components
|
|
|
|
(defcomp ~blog-cache-panel (&key clear-url csrf)
|
|
(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"
|
|
(input :type "hidden" :name "csrf_token" :value csrf)
|
|
(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)
|
|
(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)
|
|
(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 selected label)
|
|
(option :value value :selected selected label))
|
|
|
|
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility 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)
|
|
(div :class "text-xs text-stone-500" owner))
|
|
(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)
|
|
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
|
|
|
|
(defcomp ~blog-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"
|
|
:class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
(i :class "fa fa-plus") " Add Menu Item"))
|
|
(div :id "menu-item-form" :class "mb-6")
|
|
(div :id "menu-items-list" list)))
|
|
|
|
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text 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
|
|
(div :class "flex-1 min-w-0"
|
|
(div :class "font-medium truncate" label)
|
|
(div :class "text-xs text-stone-500 truncate" slug))
|
|
(div :class "text-sm text-stone-500" (str "Order: " sort-order))
|
|
(div :class "flex gap-2 flex-shrink-0"
|
|
(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"
|
|
:title "Delete menu item?" :text confirm-text
|
|
:sx-headers hx-headers))))
|
|
|
|
(defcomp ~blog-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)
|
|
(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")
|
|
(div :class "flex flex-col sm:flex-row gap-3"
|
|
(input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")
|
|
(input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")
|
|
(input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))
|
|
(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)
|
|
(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)
|
|
(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 name slug sort-order)
|
|
(li :class "border rounded p-3 bg-white flex items-center gap-3"
|
|
icon
|
|
(div :class "flex-1"
|
|
(a :href edit-href :class "font-medium text-stone-800 hover:underline" name)
|
|
(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)
|
|
(ul :class "space-y-2" items))
|
|
|
|
(defcomp ~blog-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)
|
|
(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)
|
|
(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 checked img name)
|
|
(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)
|
|
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
|
|
|
|
(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image 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"
|
|
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")
|
|
(input :type "text" :name "name" :value name :required "" :class "w-full border rounded px-3 py-2 text-sm"))
|
|
(div :class "flex gap-3"
|
|
(div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")
|
|
(input :type "text" :name "colour" :value colour :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))
|
|
(div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")
|
|
(input :type "number" :name "sort_order" :value sort-order :class "w-full border rounded px-3 py-2 text-sm")))
|
|
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")
|
|
(input :type "text" :name "feature_image" :value feature-image :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))
|
|
(div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")
|
|
(div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"
|
|
tags))
|
|
(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 csrf)
|
|
(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)
|
|
(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)
|
|
(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; }
|
|
.sx-list, .json-obj, .json-arr { display: block; }
|
|
.sx-paren { color: #64748b; }
|
|
.sx-sym { color: #0369a1; }
|
|
.sx-kw { color: #7c3aed; }
|
|
.sx-str { color: #15803d; }
|
|
.sx-num { color: #c2410c; }
|
|
.sx-bool { color: #b91c1c; font-weight: 600; }
|
|
.json-brace, .json-bracket { color: #64748b; }
|
|
.json-key { color: #7c3aed; }
|
|
.json-str { color: #15803d; }
|
|
.json-num { color: #c2410c; }
|
|
.json-lit { color: #b91c1c; font-weight: 600; }
|
|
.json-field { display: block; }
|
|
")
|
|
sections))
|
|
|
|
(defcomp ~blog-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)
|
|
(div :class "blog-content prose max-w-none" (raw! html)))
|
|
|
|
(defcomp ~blog-preview-empty ()
|
|
(div :class "p-8 text-stone-500" "No content to preview."))
|
|
|
|
(defcomp ~blog-admin-placeholder ()
|
|
(div :class "pb-8"))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Data-driven content defcomps (called from defpages with service data)
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
;; Snippets — receives serialized snippet dicts from service
|
|
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
|
|
(~blog-snippets-panel
|
|
:list (if (empty? (or snippets (list)))
|
|
(~empty-state :icon "fa fa-puzzle-piece"
|
|
:message "No snippets yet. Create one from the blog editor.")
|
|
(~blog-snippets-list
|
|
:rows (map (lambda (s)
|
|
(let* ((badge-colours (dict
|
|
"private" "bg-stone-200 text-stone-700"
|
|
"shared" "bg-blue-100 text-blue-700"
|
|
"admin" "bg-amber-100 text-amber-700"))
|
|
(vis (or (get s "visibility") "private"))
|
|
(badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
|
|
(name (get s "name"))
|
|
(owner (get s "owner"))
|
|
(can-delete (get s "can_delete")))
|
|
(~blog-snippet-row
|
|
:name name :owner owner :badge-cls badge-cls :visibility vis
|
|
:extra (<>
|
|
(when is-admin
|
|
(~blog-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"))))
|
|
(when can-delete
|
|
(~delete-btn
|
|
:url (get s "delete_url")
|
|
:trigger-target "#snippets-list"
|
|
:title "Delete snippet?"
|
|
:text (str "Delete \u201c" name "\u201d?")
|
|
:sx-headers {:X-CSRFToken csrf}
|
|
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
|
|
(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
|
|
:new-url new-url
|
|
:list (if (empty? (or menu-items (list)))
|
|
(~empty-state :icon "fa fa-inbox"
|
|
:message "No menu items yet. Add one to get started!")
|
|
(~blog-menu-items-list
|
|
:rows (map (lambda (mi)
|
|
(~blog-menu-item-row
|
|
:img (~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")
|
|
:slug (get mi "url")
|
|
:sort-order (str (or (get mi "sort_order") 0))
|
|
:edit-url (get mi "edit_url")
|
|
:delete-url (get mi "delete_url")
|
|
:confirm-text (str "Remove " (get mi "label") " from the menu?")
|
|
:hx-headers {:X-CSRFToken csrf}))
|
|
(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)
|
|
:groups (if (empty? (or groups (list)))
|
|
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
|
|
(~blog-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
|
|
:style (if colour (str "background:" colour) "background:#e7e5e4")
|
|
:initial initial))))
|
|
(~blog-tag-group-li
|
|
:icon icon
|
|
:edit-href (get g "edit_href")
|
|
:name name
|
|
:slug (or (get g "slug") "")
|
|
:sort-order (or (get g "sort_order") 0))))
|
|
(or groups (list)))))
|
|
:unassigned (when (not (empty? (or unassigned-tags (list))))
|
|
(~blog-unassigned-tags
|
|
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
|
|
:spans (map (lambda (t)
|
|
(~blog-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
|
|
: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
|
|
:tag-id (get t "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 all-tags (list))))
|
|
:delete-form (~blog-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)
|
|
(let* ((sections (list)))
|
|
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
|
|
(~blog-preview-empty)
|
|
(~blog-preview-panel :sections
|
|
(<>
|
|
(when sx-pretty
|
|
(~blog-preview-section :title "S-Expression Source" :content sx-pretty))
|
|
(when json-pretty
|
|
(~blog-preview-section :title "Lexical JSON" :content json-pretty))
|
|
(when sx-rendered
|
|
(~blog-preview-section :title "SX Rendered"
|
|
:content (~blog-preview-rendered :html sx-rendered)))
|
|
(when lex-rendered
|
|
(~blog-preview-section :title "Lexical Rendered"
|
|
:content (~blog-preview-rendered :html lex-rendered))))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Data introspection composition — replaces _h_post_data_content
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~blog-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"
|
|
(if (or (= value-type "date") (= value-type "other"))
|
|
(code value)
|
|
value))))
|
|
|
|
(defcomp ~blog-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"
|
|
(tr (th :class "px-3 py-2 text-left font-medium w-40 sm:w-56" "Field")
|
|
(th :class "px-3 py-2 text-left font-medium" "Value")))
|
|
(tbody
|
|
(map (lambda (col)
|
|
(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")))))
|
|
(or columns (list)))))))
|
|
|
|
(defcomp ~blog-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"
|
|
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
|
|
(code summary))
|
|
(when children
|
|
(div :class "mt-2 pl-3 border-l border-neutral-200"
|
|
(~blog-data-model-content
|
|
:columns (get children "columns")
|
|
:relationships (get children "relationships")))))))
|
|
|
|
(defcomp ~blog-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)
|
|
(span :class "ml-2 text-xs text-neutral-500"
|
|
cardinality " \u2192 " class-name
|
|
(when (not loaded) " \u2022 " (em "not loaded"))))
|
|
(div :class "p-3 text-sm"
|
|
(if (not value)
|
|
(span :class "text-neutral-400" "\u2014")
|
|
(if (get value "is_list")
|
|
(<>
|
|
(div :class "text-neutral-500 mb-2"
|
|
(str (get value "count") " item" (if (= (get value "count") 1) "" "s")))
|
|
(when (get value "items")
|
|
(div :class "w-full overflow-x-auto sm:overflow-visible"
|
|
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden"
|
|
(thead :class "bg-neutral-50/70"
|
|
(tr (th :class "px-2 py-1 text-left w-10" "#")
|
|
(th :class "px-2 py-1 text-left" "Summary")))
|
|
(tbody
|
|
(map (lambda (item)
|
|
(~blog-data-relationship-item
|
|
:index (get item "index")
|
|
:summary (get item "summary")
|
|
:children (get item "children")))
|
|
(get value "items")))))))
|
|
;; Single value
|
|
(<>
|
|
(pre :class "whitespace-pre-wrap break-words break-all text-xs mb-2"
|
|
(code (get value "summary")))
|
|
(when (get value "children")
|
|
(div :class "pl-3 border-l border-neutral-200"
|
|
(~blog-data-model-content
|
|
:columns (get (get value "children") "columns")
|
|
:relationships (get (get value "children") "relationships"))))))))))
|
|
|
|
(defcomp ~blog-data-model-content (&key columns relationships)
|
|
(div :class "space-y-4"
|
|
(~blog-data-scalar-table :columns columns)
|
|
(when (not (empty? (or relationships (list))))
|
|
(div :class "space-y-3"
|
|
(map (lambda (rel)
|
|
(~blog-data-relationship
|
|
:name (get rel "name")
|
|
:cardinality (get rel "cardinality")
|
|
:class-name (get rel "class_name")
|
|
:loaded (get rel "loaded")
|
|
:value (get rel "value")))
|
|
relationships)))))
|
|
|
|
(defcomp ~blog-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
|
|
:columns (get model-data "columns")
|
|
:relationships (get model-data "relationships")))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Calendar month view for browsing/toggling entries (B1)
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~blog-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"
|
|
:data-confirm "" :data-confirm-title "Remove entry?"
|
|
:data-confirm-text (str "Remove " name " from this post?")
|
|
: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 (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
|
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
|
|
(i :class "fa fa-times"))))
|
|
|
|
(defcomp ~blog-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?"
|
|
:data-confirm-text (str "Add " name " to this post?")
|
|
:data-confirm-icon "question" :data-confirm-confirm-text "Yes, add 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 (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
|
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
|
|
(span :class "truncate block" name)))
|
|
|
|
(defcomp ~blog-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)
|
|
(let* ((target (str "#calendar-view-" cal-id)))
|
|
(div :id (str "calendar-view-" cal-id)
|
|
:sx-get current-url :sx-trigger "entryToggled from:body" :sx-swap "outerHTML"
|
|
(header :class "flex items-center justify-center mb-4"
|
|
(nav :class "flex items-center gap-2 text-xl"
|
|
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
|
:sx-get prev-year-url :sx-target target :sx-swap "outerHTML"
|
|
(raw! "«"))
|
|
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
|
:sx-get prev-month-url :sx-target target :sx-swap "outerHTML"
|
|
(raw! "‹"))
|
|
(div :class "px-3 font-medium" (str month-name " " year))
|
|
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
|
:sx-get next-month-url :sx-target target :sx-swap "outerHTML"
|
|
(raw! "›"))
|
|
(a :class "px-2 py-1 hover:bg-stone-100 rounded"
|
|
:sx-get next-year-url :sx-target target :sx-swap "outerHTML"
|
|
(raw! "»"))))
|
|
(div :class "rounded border bg-white"
|
|
(div :class "hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b"
|
|
(map (lambda (wd) (div :class "py-2" wd)) (or weekday-names (list))))
|
|
(div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200"
|
|
(map (lambda (day)
|
|
(let* ((extra-cls (if (get day "in_month") "" " bg-stone-50 text-stone-400"))
|
|
(entries (or (get day "entries") (list))))
|
|
(div :class (str "min-h-20 bg-white px-2 py-2 text-xs" extra-cls)
|
|
(div :class "font-medium mb-1" (str (get day "day")))
|
|
(when (not (empty? entries))
|
|
(div :class "space-y-0.5"
|
|
(map (lambda (e)
|
|
(if (get e "is_associated")
|
|
(~blog-cal-entry-associated
|
|
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)
|
|
(~blog-cal-entry-unassociated
|
|
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
|
|
entries))))))
|
|
(or days (list))))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2)
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defcomp ~blog-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
|
|
:wrapper-id "entries-calendars-nav-wrapper"
|
|
:container-id "associated-items-container"
|
|
:arrow-cls "entries-nav-arrow"
|
|
:left-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
|
|
:scroll-hs scroll-hs
|
|
:right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
|
:items (<>
|
|
(map (lambda (e)
|
|
(~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
|
|
:href (get c "href") :nav-cls nav-cls
|
|
:name (get c "name")))
|
|
cal-list))
|
|
:oob true))))
|