From c71ca6754d284b6d71930b4843f6af3fb0dd188b Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 5 Mar 2026 01:24:37 +0000 Subject: [PATCH] Move blog composition from Python to .sx defcomps (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings form: ~135 lines raw HTML → ~blog-settings-form-content defcomp - Data introspection: ~110 lines raw HTML → ~blog-data-table-content with recursive ~blog-data-model-content defcomps, Python extracts ORM data only - Preview: sx_call composition → ~blog-preview-content defcomp - Entries browser: ~65 lines raw HTML → ~blog-entries-browser-content + ~blog-calendar-browser-item + ~blog-associated-entries-from-data defcomps - Editor panels: sx_call composition in both helpers.py and renders.py → ~blog-editor-content and ~blog-edit-content composition defcomps - renders.py: 178 → 25 lines (87% reduction) - routes.py _render_associated_entries: data extraction → single sx_call Co-Authored-By: Claude Opus 4.6 --- blog/bp/post/admin/routes.py | 50 +- blog/sx/admin.sx | 120 +++++ blog/sx/editor.sx | 45 ++ blog/sx/settings.sx | 164 +++++++ blog/sxc/pages/helpers.py | 887 +++++++++++++++++------------------ blog/sxc/pages/renders.py | 176 +------ 6 files changed, 789 insertions(+), 653 deletions(-) diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 8823bfa..d06157e 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -190,54 +190,14 @@ def _render_calendar_view( def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str: """Render the associated entries panel.""" from shared.browser.app.csrf import generate_csrf_token - from quart import url_for as qurl + from sxc.pages.helpers import _extract_associated_entries_data csrf = generate_csrf_token() + entry_data = _extract_associated_entries_data( + all_calendars, associated_entry_ids, post_slug) - has_entries = False - entry_items: list[str] = [] - for calendar in all_calendars: - entries = getattr(calendar, "entries", []) or [] - cal_name = getattr(calendar, "name", "") - cal_post = getattr(calendar, "post", None) - cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None - cal_title = getattr(cal_post, "title", "") if cal_post else "" - - for entry in entries: - e_id = getattr(entry, "id", None) - if e_id not in associated_entry_ids: - continue - if getattr(entry, "deleted_at", None) is not None: - continue - has_entries = True - e_name = getattr(entry, "name", "") - e_start = getattr(entry, "start_at", None) - e_end = getattr(entry, "end_at", None) - - toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id)) - - img_sx = sx_call("blog-entry-image", src=cal_fi, title=cal_title) - - date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else "" - if e_end: - date_str += f" \u2013 {e_end.strftime('%H:%M')}" - - entry_items.append(sx_call("blog-associated-entry", - confirm_text=f"This will remove {e_name} from this post", - toggle_url=toggle_url, - hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', - img=img_sx, name=e_name, - date_str=f"{cal_name} \u2022 {date_str}", - )) - - if has_entries: - content_sx = sx_call("blog-associated-entries-content", - items=SxExpr("(<> " + " ".join(entry_items) + ")"), - ) - else: - content_sx = sx_call("blog-associated-entries-empty") - - return sx_call("blog-associated-entries-panel", content=content_sx) + return sx_call("blog-associated-entries-from-data", + entries=entry_data, csrf=csrf) def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str: diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx index 95172ec..c7b2cd9 100644 --- a/blog/sx/admin.sx +++ b/blog/sx/admin.sx @@ -292,3 +292,123 @@ :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"))))) diff --git a/blog/sx/editor.sx b/blog/sx/editor.sx index 1704c74..c16d505 100644 --- a/blog/sx/editor.sx +++ b/blog/sx/editor.sx @@ -303,3 +303,48 @@ ;; Drag over editor ".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }")) + +;; --------------------------------------------------------------------------- +;; Editor panel composition — replaces render_editor_panel (new post/page) +;; --------------------------------------------------------------------------- + +(defcomp ~blog-editor-content (&key csrf title-placeholder create-label + css-href js-src sx-editor-js-src init-js + save-error) + (~blog-editor-panel :parts + (<> + (when save-error (~blog-editor-error :error save-error)) + (~blog-editor-form :csrf csrf :title-placeholder title-placeholder + :create-label create-label) + (~blog-editor-styles :css-href css-href) + (~sx-editor-styles) + (~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src + :init-js init-js)))) + +;; --------------------------------------------------------------------------- +;; Edit content composition — replaces _h_post_edit_content (existing post) +;; --------------------------------------------------------------------------- + +(defcomp ~blog-edit-content (&key csrf updated-at title-val excerpt-val + feature-image feature-image-caption + sx-content-val lexical-json has-sx + title-placeholder status already-emailed + newsletter-options footer-extra + css-href js-src sx-editor-js-src init-js + save-error) + (~blog-editor-panel :parts + (<> + (when save-error (~blog-editor-error :error save-error)) + (~blog-editor-edit-form + :csrf csrf :updated-at updated-at + :title-val title-val :excerpt-val excerpt-val + :feature-image feature-image :feature-image-caption feature-image-caption + :sx-content-val sx-content-val :lexical-json lexical-json + :has-sx has-sx :title-placeholder title-placeholder + :status status :already-emailed already-emailed + :newsletter-options newsletter-options :footer-extra footer-extra) + (~blog-editor-publish-js :already-emailed already-emailed) + (~blog-editor-styles :css-href css-href) + (~sx-editor-styles) + (~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src + :init-js init-js)))) diff --git a/blog/sx/settings.sx b/blog/sx/settings.sx index 41358f9..c72f04a 100644 --- a/blog/sx/settings.sx +++ b/blog/sx/settings.sx @@ -126,3 +126,167 @@ (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 (str "{\"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.")))))) diff --git a/blog/sxc/pages/helpers.py b/blog/sxc/pages/helpers.py index 7647db4..a89b493 100644 --- a/blog/sxc/pages/helpers.py +++ b/blog/sxc/pages/helpers.py @@ -94,18 +94,6 @@ async def _inject_post_context(p_data: dict) -> None: _add_to_defpage_ctx(**ctx) -# --------------------------------------------------------------------------- -# Rendering helpers (moved from sx_components) -# --------------------------------------------------------------------------- - -def _raw_html_sx(html: str) -> str: - """Wrap raw HTML in (raw! "...") so it's valid inside sx source.""" - from shared.sx.parser import serialize as sx_serialize - if not html: - return "" - return "(raw! " + sx_serialize(html) + ")" - - # --------------------------------------------------------------------------- # Page helpers (async functions available in .sx defpage expressions) # --------------------------------------------------------------------------- @@ -124,19 +112,199 @@ def _register_blog_helpers() -> None: }) -# --- Editor helpers --- +# --------------------------------------------------------------------------- +# Editor helpers +# --------------------------------------------------------------------------- + +def _editor_init_js(urls: dict, *, form_id: str = "post-edit-form", + has_initial_json: bool = True) -> str: + """Build the editor initialization JavaScript string. + + URLs dict must contain: upload_image, upload_media, upload_file, oembed, + snippets, unsplash_key. + """ + font_size_preamble = ( + "(function() {" + " function applyEditorFontSize() {" + " document.documentElement.style.fontSize = '62.5%';" + " document.body.style.fontSize = '1.6rem';" + " }" + " function restoreDefaultFontSize() {" + " document.documentElement.style.fontSize = '';" + " document.body.style.fontSize = '';" + " }" + " applyEditorFontSize();" + " document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {" + " if (e.detail.target && e.detail.target.id === 'main-panel') {" + " restoreDefaultFontSize();" + " document.body.removeEventListener('htmx:beforeSwap', cleanup);" + " }" + " });" + ) + + upload_image = urls["upload_image"] + upload_media = urls["upload_media"] + upload_file = urls["upload_file"] + oembed = urls["oembed"] + unsplash_key = urls["unsplash_key"] + snippets = urls["snippets"] + + init_body = ( + " function init() {" + " var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;" + f" var uploadUrl = '{upload_image}';" + " var uploadUrls = {" + " image: uploadUrl," + f" media: '{upload_media}'," + f" file: '{upload_file}'," + " };" + " var fileInput = document.getElementById('feature-image-file');" + " var addBtn = document.getElementById('feature-image-add-btn');" + " var deleteBtn = document.getElementById('feature-image-delete-btn');" + " var preview = document.getElementById('feature-image-preview');" + " var emptyState = document.getElementById('feature-image-empty');" + " var filledState = document.getElementById('feature-image-filled');" + " var hiddenUrl = document.getElementById('feature-image-input');" + " var hiddenCaption = document.getElementById('feature-image-caption-input');" + " var captionInput = document.getElementById('feature-image-caption');" + " var uploading = document.getElementById('feature-image-uploading');" + " function showFilled(url) {" + " preview.src = url; hiddenUrl.value = url;" + " emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');" + " }" + " function showEmpty() {" + " preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';" + " emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');" + " }" + " function uploadFile(file) {" + " emptyState.classList.add('hidden'); uploading.classList.remove('hidden');" + " var fd = new FormData(); fd.append('file', file);" + " fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })" + " .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })" + " .then(function(data) {" + " var url = data.images && data.images[0] && data.images[0].url;" + " if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }" + " })" + " .catch(function(e) { showEmpty(); alert(e.message); });" + " }" + " addBtn.addEventListener('click', function() { fileInput.click(); });" + " preview.addEventListener('click', function() { fileInput.click(); });" + " deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });" + " fileInput.addEventListener('change', function() {" + " if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = ''; }" + " });" + " captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });" + " var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');" + " function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }" + " excerpt.addEventListener('input', autoResize); autoResize();" + ) + + if has_initial_json: + init_body += ( + " var dataEl = document.getElementById('lexical-initial-data');" + " var initialJson = dataEl ? dataEl.textContent.trim() : null;" + " if (initialJson) { var hidden = document.getElementById('lexical-json-input'); if (hidden) hidden.value = initialJson; }" + ) + initial_json_arg = "initialJson: initialJson," + else: + initial_json_arg = "initialJson: null," + + init_body += ( + " window.mountEditor('lexical-editor', {" + f" {initial_json_arg}" + " csrfToken: csrfToken," + " uploadUrls: uploadUrls," + f" oembedUrl: '{oembed}'," + f" unsplashApiKey: '{unsplash_key}'," + f" snippetsUrl: '{snippets}'," + " });" + " if (typeof SxEditor !== 'undefined') {" + " SxEditor.mount('sx-editor', {" + " initialSx: (document.getElementById('sx-content-input') || {}).value || null," + " csrfToken: csrfToken," + " uploadUrls: uploadUrls," + f" oembedUrl: '{oembed}'," + " onChange: function(sx) {" + " document.getElementById('sx-content-input').value = sx;" + " }" + " });" + " }" + " document.addEventListener('keydown', function(e) {" + " if ((e.ctrlKey || e.metaKey) && e.key === 's') {" + f" e.preventDefault(); document.getElementById('{form_id}').requestSubmit();" + " }" + " });" + " }" + " if (typeof window.mountEditor === 'function') { init(); }" + " else { var _t = setInterval(function() {" + " if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }" + " }, 50); }" + "})();" + ) + + return font_size_preamble + init_body + + +def _editor_urls() -> dict: + """Extract editor API URLs and asset paths.""" + import os + from quart import url_for as qurl, current_app + + asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") + return { + "upload_image": qurl("blog.editor_api.upload_image"), + "upload_media": qurl("blog.editor_api.upload_media"), + "upload_file": qurl("blog.editor_api.upload_file"), + "oembed": qurl("blog.editor_api.oembed_proxy"), + "snippets": qurl("blog.editor_api.list_snippets"), + "unsplash_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""), + "css_href": asset_url_fn("scripts/editor.css"), + "js_src": asset_url_fn("scripts/editor.js"), + "sx_editor_js_src": asset_url_fn("scripts/sx-editor.js"), + } + def _h_editor_content(**kw): - from .renders import render_editor_panel - return render_editor_panel() + """New post editor panel.""" + from shared.sx.helpers import sx_call + from shared.browser.app.csrf import generate_csrf_token + + urls = _editor_urls() + csrf = generate_csrf_token() + init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False) + + return sx_call("blog-editor-content", + csrf=csrf, + title_placeholder="Post title...", + create_label="Create Post", + css_href=urls["css_href"], + js_src=urls["js_src"], + sx_editor_js_src=urls["sx_editor_js_src"], + init_js=init_js) def _h_editor_page_content(**kw): - from .renders import render_editor_panel - return render_editor_panel(is_page=True) + """New page editor panel.""" + from shared.sx.helpers import sx_call + from shared.browser.app.csrf import generate_csrf_token + + urls = _editor_urls() + csrf = generate_csrf_token() + init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False) + + return sx_call("blog-editor-content", + csrf=csrf, + title_placeholder="Page title...", + create_label="Create Page", + css_href=urls["css_href"], + js_src=urls["js_src"], + sx_editor_js_src=urls["sx_editor_js_src"], + init_js=init_js) -# --- Post admin helpers --- +# --------------------------------------------------------------------------- +# Post admin helpers +# --------------------------------------------------------------------------- async def _h_post_admin_content(slug=None, **kw): await _ensure_post_data(slug) @@ -144,121 +312,101 @@ async def _h_post_admin_content(slug=None, **kw): return sx_call("blog-admin-placeholder") +# --------------------------------------------------------------------------- +# Data introspection +# --------------------------------------------------------------------------- + +def _extract_model_data(obj, depth=0, max_depth=2) -> dict: + """Recursively extract ORM model data into a nested dict for .sx rendering.""" + from markupsafe import escape as esc + + # Scalar columns + columns = [] + for col in obj.__mapper__.columns: + key = col.key + if key == "_sa_instance_state": + continue + val = getattr(obj, key, None) + if val is None: + columns.append({"key": str(key), "value": "", "type": "nil"}) + elif hasattr(val, "isoformat"): + columns.append({"key": str(key), "value": str(esc(val.isoformat())), "type": "date"}) + elif isinstance(val, str): + columns.append({"key": str(key), "value": str(esc(val)), "type": "str"}) + else: + columns.append({"key": str(key), "value": str(esc(str(val))), "type": "other"}) + + # Relationships + relationships = [] + for rel in obj.__mapper__.relationships: + rel_name = rel.key + loaded = rel_name in obj.__dict__ + value = getattr(obj, rel_name, None) if loaded else None + cardinality = "many" if rel.uselist else "one" + cls_name = rel.mapper.class_.__name__ + + rel_data: dict[str, Any] = { + "name": rel_name, + "cardinality": cardinality, + "class_name": cls_name, + "loaded": loaded, + "value": None, + } + + if value is None: + pass # value stays None + elif rel.uselist: + items_list = list(value) if value else [] + val_data: dict[str, Any] = {"is_list": True, "count": len(items_list)} + if items_list and depth < max_depth: + items = [] + for i, it in enumerate(items_list, 1): + summary = _obj_summary(it) + children = _extract_model_data(it, depth + 1, max_depth) if depth < max_depth else None + items.append({"index": i, "summary": summary, "children": children}) + val_data["items"] = items + rel_data["value"] = val_data + else: + child = value + summary = _obj_summary(child) + children = _extract_model_data(child, depth + 1, max_depth) if depth < max_depth else None + rel_data["value"] = {"is_list": False, "summary": summary, "children": children} + + relationships.append(rel_data) + + return {"columns": columns, "relationships": relationships} + + +def _obj_summary(obj) -> str: + """Build a summary string for an ORM object.""" + from markupsafe import escape as esc + ident_parts = [] + for k in ("id", "ghost_id", "uuid", "slug", "name", "title"): + if k in obj.__mapper__.c: + v = getattr(obj, k, "") + ident_parts.append(f"{k}={v}") + return str(esc(" \u2022 ".join(ident_parts) if ident_parts else str(obj))) + + async def _h_post_data_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g - from markupsafe import escape as esc + from shared.sx.helpers import sx_call original_post = getattr(g, "post_data", {}).get("original_post") if original_post is None: - return _raw_html_sx('
No post data available.
') + return sx_call("blog-data-table-content") tablename = getattr(original_post, "__tablename__", "?") + model_data = _extract_model_data(original_post, 0, 2) - def _render_scalar_table(obj): - rows = [] - for col in obj.__mapper__.columns: - key = col.key - if key == "_sa_instance_state": - continue - val = getattr(obj, key, None) - if val is None: - val_html = '\u2014' - elif hasattr(val, "isoformat"): - val_html = f'
{esc(val.isoformat())}
' - elif isinstance(val, str): - val_html = f'
{esc(val)}
' - else: - val_html = f'
{esc(str(val))}
' - rows.append( - f'' - f'{esc(key)}' - f'{val_html}' - ) - return ( - '
' - '' - '' - '' - '' - '' + "".join(rows) + '
FieldValue
' - ) + return sx_call("blog-data-table-content", + tablename=tablename, model_data=model_data) - def _render_model(obj, depth=0, max_depth=2): - parts = [_render_scalar_table(obj)] - rel_parts = [] - for rel in obj.__mapper__.relationships: - rel_name = rel.key - loaded = rel_name in obj.__dict__ - value = getattr(obj, rel_name, None) if loaded else None - cardinality = "many" if rel.uselist else "one" - cls_name = rel.mapper.class_.__name__ - loaded_label = "" if loaded else " \u2022 not loaded" - - inner = "" - if value is None: - inner = '\u2014' - elif rel.uselist: - items = list(value) if value else [] - inner = f'
{len(items)} item{"" if len(items) == 1 else "s"}
' - if items and depth < max_depth: - sub_rows = [] - for i, it in enumerate(items, 1): - ident_parts = [] - for k in ("id", "ghost_id", "uuid", "slug", "name", "title"): - if k in it.__mapper__.c: - v = getattr(it, k, "") - ident_parts.append(f"{k}={v}") - summary = " \u2022 ".join(ident_parts) if ident_parts else str(it) - child_html = "" - if depth < max_depth: - child_html = f'
{_render_model(it, depth + 1, max_depth)}
' - else: - child_html = '
\u2026max depth reached\u2026
' - sub_rows.append( - f'' - f'{i}' - f'
{esc(summary)}
{child_html}' - ) - inner += ( - '
' - '' - '' - '' - + "".join(sub_rows) + '
#Summary
' - ) - else: - child = value - ident_parts = [] - for k in ("id", "ghost_id", "uuid", "slug", "name", "title"): - if k in child.__mapper__.c: - v = getattr(child, k, "") - ident_parts.append(f"{k}={v}") - summary = " \u2022 ".join(ident_parts) if ident_parts else str(child) - inner = f'
{esc(summary)}
' - if depth < max_depth: - inner += f'
{_render_model(child, depth + 1, max_depth)}
' - else: - inner += '
\u2026max depth reached\u2026
' - - rel_parts.append( - f'
' - f'
' - f'Relationship: {esc(rel_name)}' - f' {cardinality} \u2192 {esc(cls_name)}{loaded_label}
' - f'
{inner}
' - ) - if rel_parts: - parts.append('
' + "".join(rel_parts) + '
') - return '
' + "".join(parts) + '
' - - html = ( - f'
' - f'
Model: Post \u2022 Table: {esc(tablename)}
' - f'{_render_model(original_post, 0, 2)}
' - ) - return _raw_html_sx(html) +# --------------------------------------------------------------------------- +# Preview content +# --------------------------------------------------------------------------- async def _h_post_preview_content(slug=None, **kw): await _ensure_post_data(slug) @@ -269,38 +417,91 @@ async def _h_post_preview_content(slug=None, **kw): preview = await services.blog_page.preview_data(g.s) - sections: list[str] = [] - if preview.get("sx_pretty"): - sections.append(sx_call("blog-preview-section", - title="S-Expression Source", content=SxExpr(preview["sx_pretty"]))) - if preview.get("json_pretty"): - sections.append(sx_call("blog-preview-section", - title="Lexical JSON", content=SxExpr(preview["json_pretty"]))) - if preview.get("sx_rendered"): - rendered_sx = sx_call("blog-preview-rendered", html=preview["sx_rendered"]) - sections.append(sx_call("blog-preview-section", - title="SX Rendered", content=rendered_sx)) - if preview.get("lex_rendered"): - rendered_sx = sx_call("blog-preview-rendered", html=preview["lex_rendered"]) - sections.append(sx_call("blog-preview-section", - title="Lexical Rendered", content=rendered_sx)) + return sx_call("blog-preview-content", + sx_pretty=SxExpr(preview["sx_pretty"]) if preview.get("sx_pretty") else None, + json_pretty=SxExpr(preview["json_pretty"]) if preview.get("json_pretty") else None, + sx_rendered=preview.get("sx_rendered") or None, + lex_rendered=preview.get("lex_rendered") or None) - if not sections: - return sx_call("blog-preview-empty") - inner = " ".join(sections) - return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})")) +# --------------------------------------------------------------------------- +# Entries browser +# --------------------------------------------------------------------------- + +def _extract_associated_entries_data(all_calendars, associated_entry_ids, post_slug: str) -> list: + """Extract associated entry data for .sx rendering.""" + from quart import url_for as qurl + from shared.utils import host_url + + entries = [] + for calendar in all_calendars: + cal_entries = getattr(calendar, "entries", []) or [] + cal_name = getattr(calendar, "name", "") + cal_post = getattr(calendar, "post", None) + cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None + cal_title = getattr(cal_post, "title", "") if cal_post else "" + + for entry in cal_entries: + e_id = getattr(entry, "id", None) + if e_id not in associated_entry_ids: + continue + if getattr(entry, "deleted_at", None) is not None: + continue + + e_name = getattr(entry, "name", "") + e_start = getattr(entry, "start_at", None) + e_end = getattr(entry, "end_at", None) + + toggle_url = host_url(qurl("blog.post.admin.toggle_entry", + slug=post_slug, entry_id=e_id)) + + date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else "" + if e_end: + date_str += f" \u2013 {e_end.strftime('%H:%M')}" + + entries.append({ + "name": e_name, + "confirm_text": f"This will remove {e_name} from this post", + "toggle_url": toggle_url, + "cal_image": cal_fi or "", + "cal_title": cal_title, + "date_str": f"{cal_name} \u2022 {date_str}", + }) + + return entries + + +def _extract_calendar_browser_data(all_calendars, post_slug: str) -> list: + """Extract calendar browser data for .sx rendering.""" + from quart import url_for as qurl + from shared.utils import host_url + + calendars = [] + for cal in all_calendars: + cal_post = getattr(cal, "post", None) + cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None + cal_title = getattr(cal_post, "title", "") if cal_post else "" + cal_name = getattr(cal, "name", "") + view_url = host_url(qurl("blog.post.admin.calendar_view", + slug=post_slug, calendar_id=cal.id)) + calendars.append({ + "name": cal_name, + "title": cal_title, + "image": cal_fi or "", + "view_url": view_url, + }) + return calendars async def _h_post_entries_content(slug=None, **kw): await _ensure_post_data(slug) - from quart import g, url_for as qurl + from quart import g from sqlalchemy import select - from markupsafe import escape as esc from shared.models.calendars import Calendar - from shared.utils import host_url + from shared.sx.helpers import sx_call + from shared.sx.parser import SxExpr + from shared.browser.app.csrf import generate_csrf_token from bp.post.services.entry_associations import get_post_entry_ids - from bp.post.admin.routes import _render_associated_entries post_id = g.post_data["post"]["id"] post_slug = g.post_data["post"]["slug"] @@ -314,59 +515,31 @@ async def _h_post_entries_content(slug=None, **kw): for calendar in all_calendars: await g.s.refresh(calendar, ["entries", "post"]) - # Associated entries list - assoc_html = _render_associated_entries(all_calendars, associated_entry_ids, post_slug) + csrf = generate_csrf_token() + entry_data = _extract_associated_entries_data( + all_calendars, associated_entry_ids, post_slug) + calendar_data = _extract_calendar_browser_data(all_calendars, post_slug) - # Calendar browser - cal_items: list[str] = [] - for cal in all_calendars: - cal_post = getattr(cal, "post", None) - cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None - cal_title = esc(getattr(cal_post, "title", "")) if cal_post else "" - cal_name = esc(getattr(cal, "name", "")) - cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id)) + entries_panel = sx_call("blog-associated-entries-from-data", + entries=entry_data, csrf=csrf) - img_html = ( - f'{cal_title}' - if cal_fi else - '
' - ) - cal_items.append( - f'
' - f'' - f'{img_html}' - f'
' - f'
{cal_name}
' - f'
{cal_title}
' - f'
' - f'
' - f'
Loading calendar...
' - f'
' - ) + return sx_call("blog-entries-browser-content", + entries_panel=SxExpr(entries_panel), + calendars=calendar_data) - if cal_items: - browser_html = ( - '

Browse Calendars

' - + "".join(cal_items) + '
' - ) - else: - browser_html = '

Browse Calendars

No calendars found.
' - - return ( - _raw_html_sx('
') - + assoc_html - + _raw_html_sx(browser_html + '
') - ) +# --------------------------------------------------------------------------- +# Settings form +# --------------------------------------------------------------------------- async def _h_post_settings_content(slug=None, **kw): await _ensure_post_data(slug) from quart import g, request - from markupsafe import escape as esc from models.ghost_content import Post from sqlalchemy import select as sa_select from sqlalchemy.orm import selectinload from shared.browser.app.csrf import generate_csrf_token + from shared.sx.helpers import sx_call from bp.post.admin.routes import _post_to_edit_dict post_id = g.post_data["post"]["id"] @@ -383,123 +556,82 @@ async def _h_post_settings_content(slug=None, **kw): is_page = p.get("is_page", False) gp = ghost_post - def field_label(text, field_for=None): - for_attr = f' for="{field_for}"' if field_for else '' - return f'{esc(text)}' - - 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 = input_cls + ' resize-y' - - def text_input(name, value='', placeholder='', input_type='text', maxlength=None): - ml = f' maxlength="{maxlength}"' if maxlength else '' - return (f'') - - def textarea_input(name, value='', placeholder='', rows=3, maxlength=None): - ml = f' maxlength="{maxlength}"' if maxlength else '' - return (f'') - - def checkbox_input(name, checked=False, label=''): - chk = ' checked' if checked else '' - return (f'') - - def section(title, content, is_open=False): - open_attr = ' open' if is_open else '' - return (f'
' - f'{esc(title)}' - f'
{content}
') - - # General section - slug_placeholder = 'page-slug' if is_page else 'post-slug' - pub_at = gp.get("published_at") or "" - pub_at_val = pub_at[:16] if pub_at else "" - vis = gp.get("visibility") or "public" - vis_opts = "".join( - f'' - for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")] - ) - - general = ( - f'
{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}
' - f'
{field_label("Published at", "settings-published_at")}' - f'
' - f'
{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}
' - f'
{field_label("Visibility", "settings-visibility")}' - f'
' - f'
{checkbox_input("email_only", gp.get("email_only"), "Email only")}
' - ) - - # Tags + # Extract tag names tags = gp.get("tags") or [] if tags: - tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags) + tag_names = ", ".join( + getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) + for t in tags + ) else: tag_names = "" - tags_sec = ( - f'
{field_label("Tags (comma-separated)", "settings-tags")}' - f'{text_input("tags", tag_names, "news, updates, featured")}' - f'

Unknown tags will be created automatically.

' - ) - fi_sec = f'
{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}
' + # Published at — trim to datetime-local format + pub_at = gp.get("published_at") or "" + pub_at_val = pub_at[:16] if pub_at else "" - seo_sec = ( - f'
{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}' - f'

Recommended: 70 characters. Max: 300.

' - f'
{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}' - f'

Recommended: 156 characters.

' - f'
{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}
' - ) + return sx_call("blog-settings-form-content", + csrf=csrf, + updated_at=gp.get("updated_at") or "", + is_page=is_page, + save_success=save_success, + slug=gp.get("slug") or "", + published_at=pub_at_val, + featured=bool(gp.get("featured")), + visibility=gp.get("visibility") or "public", + email_only=bool(gp.get("email_only")), + tags=tag_names, + feature_image_alt=gp.get("feature_image_alt") or "", + meta_title=gp.get("meta_title") or "", + meta_description=gp.get("meta_description") or "", + canonical_url=gp.get("canonical_url") or "", + og_title=gp.get("og_title") or "", + og_description=gp.get("og_description") or "", + og_image=gp.get("og_image") or "", + twitter_title=gp.get("twitter_title") or "", + twitter_description=gp.get("twitter_description") or "", + twitter_image=gp.get("twitter_image") or "", + custom_template=gp.get("custom_template") or "") - og_sec = ( - f'
{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}
' - f'
{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}
' - f'
{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}
' - ) - tw_sec = ( - f'
{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}
' - f'
{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}
' - f'
{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}
' - ) +# --------------------------------------------------------------------------- +# Post edit content +# --------------------------------------------------------------------------- - tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs' - adv_sec = f'
{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}
' +def _extract_newsletter_options(newsletters) -> list: + """Extract newsletter data for .sx rendering.""" + return [{"slug": getattr(nl, "slug", ""), + "name": getattr(nl, "name", "")} for nl in newsletters] - sections = ( - section("General", general, is_open=True) - + section("Tags", tags_sec) - + section("Feature Image", fi_sec) - + section("SEO / Meta", seo_sec) - + section("Facebook / OpenGraph", og_sec) - + section("X / Twitter", tw_sec) - + section("Advanced", adv_sec) - ) - saved_html = 'Saved.' if save_success else '' - - html = ( - f'
' - f'' - f'' - f'
{sections}
' - f'
' - f'' - f'{saved_html}
' - ) - return _raw_html_sx(html) +def _extract_footer_badges(ghost_post: dict, post: dict, save_success: bool, + publish_requested: bool, already_emailed: bool) -> list: + """Extract footer badge data for .sx rendering.""" + badges = [] + if save_success: + badges.append({"cls": "text-[14px] text-green-600", "text": "Saved."}) + if publish_requested: + badges.append({"cls": "text-[14px] text-blue-600", + "text": "Publish requested \u2014 an admin will review."}) + if post.get("publish_requested"): + badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800", + "text": "Publish requested"}) + if already_emailed: + nl_name = "" + newsletter = ghost_post.get("newsletter") + if newsletter: + nl_name = (getattr(newsletter, "name", "") + if not isinstance(newsletter, dict) + else newsletter.get("name", "")) + suffix = f" to {nl_name}" if nl_name else "" + badges.append({"cls": "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800", + "text": f"Emailed{suffix}"}) + return badges async def _h_post_edit_content(slug=None, **kw): await _ensure_post_data(slug) - import os - from quart import g, request as qrequest, url_for as qurl, current_app + from quart import g, request as qrequest from models.ghost_content import Post from sqlalchemy import select as sa_select from sqlalchemy.orm import selectinload @@ -523,17 +655,7 @@ async def _h_post_edit_content(slug=None, **kw): newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] csrf = generate_csrf_token() - asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") - editor_css = asset_url_fn("scripts/editor.css") - editor_js = asset_url_fn("scripts/editor.js") - sx_editor_js = asset_url_fn("scripts/sx-editor.js") - - upload_image_url = qurl("blog.editor_api.upload_image") - upload_media_url = qurl("blog.editor_api.upload_media") - upload_file_url = qurl("blog.editor_api.upload_file") - oembed_url = qurl("blog.editor_api.oembed_proxy") - snippets_url = qurl("blog.editor_api.list_snippets") - unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "") + urls = _editor_urls() post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {} is_page = post.get("is_page", False) @@ -548,7 +670,8 @@ async def _h_post_edit_content(slug=None, **kw): sx_content = ghost_post.get("sx_content") or "" has_sx = bool(sx_content) - already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status")) + already_emailed = bool(ghost_post and ghost_post.get("email") and + (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status")) email_obj = ghost_post.get("email") if email_obj and not isinstance(email_obj, dict): already_emailed = bool(getattr(email_obj, "status", None)) @@ -564,151 +687,27 @@ async def _h_post_edit_content(slug=None, **kw): nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")") # Footer extra badges as SX fragment - badge_parts: list[str] = [] - if save_success: - badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")') - publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None - if publish_requested: - badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")') - if post.get("publish_requested"): - badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")') - if already_emailed: - nl_name = "" - newsletter = ghost_post.get("newsletter") - if newsletter: - nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "") - suffix = f" to {nl_name}" if nl_name else "" - badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")') - footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None + publish_requested = bool(qrequest.args.get("publish_requested")) if hasattr(qrequest, 'args') else False + badges = _extract_footer_badges(ghost_post, post, save_success, + publish_requested, already_emailed) + if badges: + badge_parts = [f'(span :class "{b["cls"]}" {sx_serialize(b["text"])})' + for b in badges] + footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") + else: + footer_extra_sx = None - parts: list[str] = [] + init_js = _editor_init_js(urls, form_id="post-edit-form", has_initial_json=True) - if save_error: - parts.append(sx_call("blog-editor-error", error=save_error)) - - parts.append(sx_call("blog-editor-edit-form", - csrf=csrf, - updated_at=str(updated_at), - title_val=title_val, - excerpt_val=excerpt_val, + return sx_call("blog-edit-content", + csrf=csrf, updated_at=str(updated_at), + title_val=title_val, excerpt_val=excerpt_val, feature_image=feature_image, feature_image_caption=feature_image_caption, - sx_content_val=sx_content, - lexical_json=lexical_json, - has_sx=has_sx, - title_placeholder=title_placeholder, - status=status, - already_emailed=already_emailed, - newsletter_options=nl_opts_sx, - footer_extra=footer_extra_sx, - )) - - parts.append(sx_call("blog-editor-publish-js", already_emailed=already_emailed)) - parts.append(sx_call("blog-editor-styles", css_href=editor_css)) - parts.append(sx_call("sx-editor-styles")) - - init_js = ( - '(function() {' - " function applyEditorFontSize() {" - " document.documentElement.style.fontSize = '62.5%';" - " document.body.style.fontSize = '1.6rem';" - ' }' - " function restoreDefaultFontSize() {" - " document.documentElement.style.fontSize = '';" - " document.body.style.fontSize = '';" - ' }' - ' applyEditorFontSize();' - " document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {" - " if (e.detail.target && e.detail.target.id === 'main-panel') {" - ' restoreDefaultFontSize();' - " document.body.removeEventListener('htmx:beforeSwap', cleanup);" - ' }' - ' });' - ' function init() {' - " var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;" - f" var uploadUrl = '{upload_image_url}';" - ' var uploadUrls = {' - ' image: uploadUrl,' - f" media: '{upload_media_url}'," - f" file: '{upload_file_url}'," - ' };' - " var fileInput = document.getElementById('feature-image-file');" - " var addBtn = document.getElementById('feature-image-add-btn');" - " var deleteBtn = document.getElementById('feature-image-delete-btn');" - " var preview = document.getElementById('feature-image-preview');" - " var emptyState = document.getElementById('feature-image-empty');" - " var filledState = document.getElementById('feature-image-filled');" - " var hiddenUrl = document.getElementById('feature-image-input');" - " var hiddenCaption = document.getElementById('feature-image-caption-input');" - " var captionInput = document.getElementById('feature-image-caption');" - " var uploading = document.getElementById('feature-image-uploading');" - ' function showFilled(url) {' - ' preview.src = url; hiddenUrl.value = url;' - " emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');" - ' }' - ' function showEmpty() {' - " preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';" - " emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');" - ' }' - ' function uploadFile(file) {' - " emptyState.classList.add('hidden'); uploading.classList.remove('hidden');" - " var fd = new FormData(); fd.append('file', file);" - " fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })" - " .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })" - ' .then(function(data) {' - ' var url = data.images && data.images[0] && data.images[0].url;' - " if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }" - ' })' - ' .catch(function(e) { showEmpty(); alert(e.message); });' - ' }' - " addBtn.addEventListener('click', function() { fileInput.click(); });" - " preview.addEventListener('click', function() { fileInput.click(); });" - " deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });" - " fileInput.addEventListener('change', function() {" - ' if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }' - ' });' - " captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });" - " var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');" - " function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }" - " excerpt.addEventListener('input', autoResize); autoResize();" - ' var dataEl = document.getElementById(\'lexical-initial-data\');' - ' var initialJson = dataEl ? dataEl.textContent.trim() : null;' - ' if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }' - " window.mountEditor('lexical-editor', {" - ' initialJson: initialJson,' - ' csrfToken: csrfToken,' - ' uploadUrls: uploadUrls,' - f" oembedUrl: '{oembed_url}'," - f" unsplashApiKey: '{unsplash_key}'," - f" snippetsUrl: '{snippets_url}'," - ' });' - " if (typeof SxEditor !== 'undefined') {" - " SxEditor.mount('sx-editor', {" - " initialSx: (document.getElementById('sx-content-input') || {}).value || null," - ' csrfToken: csrfToken,' - ' uploadUrls: uploadUrls,' - f" oembedUrl: '{oembed_url}'," - ' onChange: function(sx) {' - " document.getElementById('sx-content-input').value = sx;" - ' }' - ' });' - ' }' - " document.addEventListener('keydown', function(e) {" - " if ((e.ctrlKey || e.metaKey) && e.key === 's') {" - " e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();" - ' }' - ' });' - ' }' - " if (typeof window.mountEditor === 'function') { init(); }" - ' else { var _t = setInterval(function() {' - " if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }" - ' }, 50); }' - '})();' - ) - parts.append(sx_call("blog-editor-scripts", - js_src=editor_js, - sx_editor_js_src=sx_editor_js, - init_js=init_js)) - - return sx_call("blog-editor-panel", - parts=SxExpr("(<> " + " ".join(parts) + ")")) + sx_content_val=sx_content, lexical_json=lexical_json, + has_sx=has_sx, title_placeholder=title_placeholder, + status=status, already_emailed=already_emailed, + newsletter_options=nl_opts_sx, footer_extra=footer_extra_sx, + css_href=urls["css_href"], js_src=urls["js_src"], + sx_editor_js_src=urls["sx_editor_js_src"], + init_js=init_js, save_error=save_error or None) diff --git a/blog/sxc/pages/renders.py b/blog/sxc/pages/renders.py index 3cd1833..4c344a2 100644 --- a/blog/sxc/pages/renders.py +++ b/blog/sxc/pages/renders.py @@ -3,175 +3,23 @@ from __future__ import annotations def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str: - """Build the WYSIWYG editor panel HTML for new post/page creation.""" - import os - from quart import url_for as qurl, current_app + """Build the WYSIWYG editor panel for new post/page creation.""" from shared.browser.app.csrf import generate_csrf_token from shared.sx.helpers import sx_call + from .helpers import _editor_urls, _editor_init_js + urls = _editor_urls() csrf = generate_csrf_token() - asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") - editor_css = asset_url_fn("scripts/editor.css") - editor_js = asset_url_fn("scripts/editor.js") - sx_editor_js = asset_url_fn("scripts/sx-editor.js") - - upload_image_url = qurl("blog.editor_api.upload_image") - upload_media_url = qurl("blog.editor_api.upload_media") - upload_file_url = qurl("blog.editor_api.upload_file") - oembed_url = qurl("blog.editor_api.oembed_proxy") - snippets_url = qurl("blog.editor_api.list_snippets") - unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "") - title_placeholder = "Page title..." if is_page else "Post title..." create_label = "Create Page" if is_page else "Create Post" + init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False) - parts: list[str] = [] - - if save_error: - parts.append(sx_call("blog-editor-error", error=str(save_error))) - - parts.append(sx_call("blog-editor-form", - csrf=csrf, title_placeholder=title_placeholder, + return sx_call("blog-editor-content", + csrf=csrf, + title_placeholder=title_placeholder, create_label=create_label, - )) - - parts.append(sx_call("blog-editor-styles", css_href=editor_css)) - parts.append(sx_call("sx-editor-styles")) - - init_js = ( - "console.log('[EDITOR-DEBUG] init script running');\n" - "(function() {\n" - " console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n" - " function init() {\n" - " var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n" - f" var uploadUrl = '{upload_image_url}';\n" - " var uploadUrls = {\n" - " image: uploadUrl,\n" - f" media: '{upload_media_url}',\n" - f" file: '{upload_file_url}',\n" - " };\n" - "\n" - " var fileInput = document.getElementById('feature-image-file');\n" - " var addBtn = document.getElementById('feature-image-add-btn');\n" - " var deleteBtn = document.getElementById('feature-image-delete-btn');\n" - " var preview = document.getElementById('feature-image-preview');\n" - " var emptyState = document.getElementById('feature-image-empty');\n" - " var filledState = document.getElementById('feature-image-filled');\n" - " var hiddenUrl = document.getElementById('feature-image-input');\n" - " var hiddenCaption = document.getElementById('feature-image-caption-input');\n" - " var captionInput = document.getElementById('feature-image-caption');\n" - " var uploading = document.getElementById('feature-image-uploading');\n" - "\n" - " function showFilled(url) {\n" - " preview.src = url;\n" - " hiddenUrl.value = url;\n" - " emptyState.classList.add('hidden');\n" - " filledState.classList.remove('hidden');\n" - " uploading.classList.add('hidden');\n" - " }\n" - "\n" - " function showEmpty() {\n" - " preview.src = '';\n" - " hiddenUrl.value = '';\n" - " hiddenCaption.value = '';\n" - " captionInput.value = '';\n" - " emptyState.classList.remove('hidden');\n" - " filledState.classList.add('hidden');\n" - " uploading.classList.add('hidden');\n" - " }\n" - "\n" - " function uploadFile(file) {\n" - " emptyState.classList.add('hidden');\n" - " uploading.classList.remove('hidden');\n" - " var fd = new FormData();\n" - " fd.append('file', file);\n" - " fetch(uploadUrl, {\n" - " method: 'POST',\n" - " body: fd,\n" - " headers: { 'X-CSRFToken': csrfToken },\n" - " })\n" - " .then(function(r) {\n" - " if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n" - " return r.json();\n" - " })\n" - " .then(function(data) {\n" - " var url = data.images && data.images[0] && data.images[0].url;\n" - " if (url) showFilled(url);\n" - " else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n" - " })\n" - " .catch(function(e) {\n" - " showEmpty();\n" - " alert(e.message);\n" - " });\n" - " }\n" - "\n" - " addBtn.addEventListener('click', function() { fileInput.click(); });\n" - " preview.addEventListener('click', function() { fileInput.click(); });\n" - " deleteBtn.addEventListener('click', function(e) {\n" - " e.stopPropagation();\n" - " showEmpty();\n" - " });\n" - " fileInput.addEventListener('change', function() {\n" - " if (fileInput.files && fileInput.files[0]) {\n" - " uploadFile(fileInput.files[0]);\n" - " fileInput.value = '';\n" - " }\n" - " });\n" - " captionInput.addEventListener('input', function() {\n" - " hiddenCaption.value = captionInput.value;\n" - " });\n" - "\n" - " var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n" - " function autoResize() {\n" - " excerpt.style.height = 'auto';\n" - " excerpt.style.height = excerpt.scrollHeight + 'px';\n" - " }\n" - " excerpt.addEventListener('input', autoResize);\n" - " autoResize();\n" - "\n" - " window.mountEditor('lexical-editor', {\n" - " initialJson: null,\n" - " csrfToken: csrfToken,\n" - " uploadUrls: uploadUrls,\n" - f" oembedUrl: '{oembed_url}',\n" - f" unsplashApiKey: '{unsplash_key}',\n" - f" snippetsUrl: '{snippets_url}',\n" - " });\n" - "\n" - " if (typeof SxEditor !== 'undefined') {\n" - " SxEditor.mount('sx-editor', {\n" - " initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n" - " csrfToken: csrfToken,\n" - " uploadUrls: uploadUrls,\n" - f" oembedUrl: '{oembed_url}',\n" - " onChange: function(sx) {\n" - " document.getElementById('sx-content-input').value = sx;\n" - " }\n" - " });\n" - " }\n" - "\n" - " document.addEventListener('keydown', function(e) {\n" - " if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n" - " e.preventDefault();\n" - " document.getElementById('post-new-form').requestSubmit();\n" - " }\n" - " });\n" - " }\n" - "\n" - " if (typeof window.mountEditor === 'function') {\n" - " init();\n" - " } else {\n" - " var _t = setInterval(function() {\n" - " if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n" - " }, 50);\n" - " }\n" - "})();\n" - ) - parts.append(sx_call("blog-editor-scripts", - js_src=editor_js, - sx_editor_js_src=sx_editor_js, - init_js=init_js)) - - from shared.sx.parser import SxExpr - return sx_call("blog-editor-panel", - parts=SxExpr("(<> " + " ".join(parts) + ")")) if parts else "" + css_href=urls["css_href"], + js_src=urls["js_src"], + sx_editor_js_src=urls["sx_editor_js_src"], + init_js=init_js, + save_error=save_error or None)