Move blog composition from Python to .sx defcomps (Phase 4)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -190,54 +190,14 @@ def _render_calendar_view(
|
|||||||
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
||||||
"""Render the associated entries panel."""
|
"""Render the associated entries panel."""
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
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()
|
csrf = generate_csrf_token()
|
||||||
|
entry_data = _extract_associated_entries_data(
|
||||||
|
all_calendars, associated_entry_ids, post_slug)
|
||||||
|
|
||||||
has_entries = False
|
return sx_call("blog-associated-entries-from-data",
|
||||||
entry_items: list[str] = []
|
entries=entry_data, csrf=csrf)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
||||||
|
|||||||
120
blog/sx/admin.sx
120
blog/sx/admin.sx
@@ -292,3 +292,123 @@
|
|||||||
:name (get t "name")))
|
:name (get t "name")))
|
||||||
(or all-tags (list))))
|
(or all-tags (list))))
|
||||||
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
|
: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")))))
|
||||||
|
|||||||
@@ -303,3 +303,48 @@
|
|||||||
|
|
||||||
;; Drag over editor
|
;; Drag over editor
|
||||||
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
|
".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))))
|
||||||
|
|||||||
@@ -126,3 +126,167 @@
|
|||||||
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
||||||
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
||||||
content))
|
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."))))))
|
||||||
|
|||||||
@@ -94,18 +94,6 @@ async def _inject_post_context(p_data: dict) -> None:
|
|||||||
_add_to_defpage_ctx(**ctx)
|
_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)
|
# 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):
|
def _h_editor_content(**kw):
|
||||||
from .renders import render_editor_panel
|
"""New post editor panel."""
|
||||||
return render_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):
|
def _h_editor_page_content(**kw):
|
||||||
from .renders import render_editor_panel
|
"""New page editor panel."""
|
||||||
return render_editor_panel(is_page=True)
|
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):
|
async def _h_post_admin_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
@@ -144,121 +312,101 @@ async def _h_post_admin_content(slug=None, **kw):
|
|||||||
return sx_call("blog-admin-placeholder")
|
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):
|
async def _h_post_data_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from quart import g
|
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")
|
original_post = getattr(g, "post_data", {}).get("original_post")
|
||||||
if original_post is None:
|
if original_post is None:
|
||||||
return _raw_html_sx('<div class="px-4 py-8 text-stone-400">No post data available.</div>')
|
return sx_call("blog-data-table-content")
|
||||||
|
|
||||||
tablename = getattr(original_post, "__tablename__", "?")
|
tablename = getattr(original_post, "__tablename__", "?")
|
||||||
|
model_data = _extract_model_data(original_post, 0, 2)
|
||||||
|
|
||||||
def _render_scalar_table(obj):
|
return sx_call("blog-data-table-content",
|
||||||
rows = []
|
tablename=tablename, model_data=model_data)
|
||||||
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 = '<span class="text-neutral-400">\u2014</span>'
|
|
||||||
elif hasattr(val, "isoformat"):
|
|
||||||
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(val.isoformat())}</code></pre>'
|
|
||||||
elif isinstance(val, str):
|
|
||||||
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs">{esc(val)}</pre>'
|
|
||||||
else:
|
|
||||||
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(str(val))}</code></pre>'
|
|
||||||
rows.append(
|
|
||||||
f'<tr class="border-t border-neutral-200 align-top">'
|
|
||||||
f'<td class="px-3 py-2 whitespace-nowrap text-neutral-600 align-top">{esc(key)}</td>'
|
|
||||||
f'<td class="px-3 py-2 align-top">{val_html}</td></tr>'
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
'<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>'
|
|
||||||
'<th class="px-3 py-2 text-left font-medium">Value</th>'
|
|
||||||
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table></div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
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 <em>not loaded</em>"
|
|
||||||
|
|
||||||
inner = ""
|
|
||||||
if value is None:
|
|
||||||
inner = '<span class="text-neutral-400">\u2014</span>'
|
|
||||||
elif rel.uselist:
|
|
||||||
items = list(value) if value else []
|
|
||||||
inner = f'<div class="text-neutral-500 mb-2">{len(items)} item{"" if len(items) == 1 else "s"}</div>'
|
|
||||||
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'<div class="mt-2 pl-3 border-l border-neutral-200">{_render_model(it, depth + 1, max_depth)}</div>'
|
|
||||||
else:
|
|
||||||
child_html = '<div class="mt-1 text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
|
|
||||||
sub_rows.append(
|
|
||||||
f'<tr class="border-t border-neutral-200 align-top">'
|
|
||||||
f'<td class="px-2 py-1 whitespace-nowrap align-top">{i}</td>'
|
|
||||||
f'<td class="px-2 py-1 align-top"><pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(summary)}</code></pre>{child_html}</td></tr>'
|
|
||||||
)
|
|
||||||
inner += (
|
|
||||||
'<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>'
|
|
||||||
'<th class="px-2 py-1 text-left">Summary</th></tr></thead><tbody>'
|
|
||||||
+ "".join(sub_rows) + '</tbody></table></div>'
|
|
||||||
)
|
|
||||||
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'<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{esc(summary)}</code></pre>'
|
|
||||||
if depth < max_depth:
|
|
||||||
inner += f'<div class="pl-3 border-l border-neutral-200">{_render_model(child, depth + 1, max_depth)}</div>'
|
|
||||||
else:
|
|
||||||
inner += '<div class="text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
|
|
||||||
|
|
||||||
rel_parts.append(
|
|
||||||
f'<div class="rounded-xl border border-neutral-200">'
|
|
||||||
f'<div class="px-3 py-2 bg-neutral-50/70 text-sm font-medium">'
|
|
||||||
f'Relationship: <span class="font-semibold">{esc(rel_name)}</span>'
|
|
||||||
f' <span class="ml-2 text-xs text-neutral-500">{cardinality} \u2192 {esc(cls_name)}{loaded_label}</span></div>'
|
|
||||||
f'<div class="p-3 text-sm">{inner}</div></div>'
|
|
||||||
)
|
|
||||||
if rel_parts:
|
|
||||||
parts.append('<div class="space-y-3">' + "".join(rel_parts) + '</div>')
|
|
||||||
return '<div class="space-y-4">' + "".join(parts) + '</div>'
|
|
||||||
|
|
||||||
html = (
|
|
||||||
f'<div class="px-4 py-8">'
|
|
||||||
f'<div class="mb-6 text-sm text-neutral-500">Model: <code>Post</code> \u2022 Table: <code>{esc(tablename)}</code></div>'
|
|
||||||
f'{_render_model(original_post, 0, 2)}</div>'
|
|
||||||
)
|
|
||||||
return _raw_html_sx(html)
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview content
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _h_post_preview_content(slug=None, **kw):
|
async def _h_post_preview_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
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)
|
preview = await services.blog_page.preview_data(g.s)
|
||||||
|
|
||||||
sections: list[str] = []
|
return sx_call("blog-preview-content",
|
||||||
if preview.get("sx_pretty"):
|
sx_pretty=SxExpr(preview["sx_pretty"]) if preview.get("sx_pretty") else None,
|
||||||
sections.append(sx_call("blog-preview-section",
|
json_pretty=SxExpr(preview["json_pretty"]) if preview.get("json_pretty") else None,
|
||||||
title="S-Expression Source", content=SxExpr(preview["sx_pretty"])))
|
sx_rendered=preview.get("sx_rendered") or None,
|
||||||
if preview.get("json_pretty"):
|
lex_rendered=preview.get("lex_rendered") or None)
|
||||||
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))
|
|
||||||
|
|
||||||
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):
|
async def _h_post_entries_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from quart import g, url_for as qurl
|
from quart import g
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from markupsafe import escape as esc
|
|
||||||
from shared.models.calendars import Calendar
|
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.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_id = g.post_data["post"]["id"]
|
||||||
post_slug = g.post_data["post"]["slug"]
|
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:
|
for calendar in all_calendars:
|
||||||
await g.s.refresh(calendar, ["entries", "post"])
|
await g.s.refresh(calendar, ["entries", "post"])
|
||||||
|
|
||||||
# Associated entries list
|
csrf = generate_csrf_token()
|
||||||
assoc_html = _render_associated_entries(all_calendars, associated_entry_ids, post_slug)
|
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
|
entries_panel = sx_call("blog-associated-entries-from-data",
|
||||||
cal_items: list[str] = []
|
entries=entry_data, csrf=csrf)
|
||||||
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))
|
|
||||||
|
|
||||||
img_html = (
|
return sx_call("blog-entries-browser-content",
|
||||||
f'<img src="{esc(cal_fi)}" alt="{cal_title}" class="w-12 h-12 rounded object-cover flex-shrink-0" />'
|
entries_panel=SxExpr(entries_panel),
|
||||||
if cal_fi else
|
calendars=calendar_data)
|
||||||
'<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>'
|
|
||||||
)
|
|
||||||
cal_items.append(
|
|
||||||
f'<details class="border rounded-lg bg-white" data-toggle-group="calendar-browser">'
|
|
||||||
f'<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">'
|
|
||||||
f'{img_html}'
|
|
||||||
f'<div class="flex-1">'
|
|
||||||
f'<div class="font-semibold flex items-center gap-2"><i class="fa fa-calendar text-stone-500"></i> {cal_name}</div>'
|
|
||||||
f'<div class="text-sm text-stone-600">{cal_title}</div>'
|
|
||||||
f'</div></summary>'
|
|
||||||
f'<div class="p-4 border-t" sx-get="{esc(cal_view_url)}" sx-trigger="intersect once" sx-swap="innerHTML">'
|
|
||||||
f'<div class="text-sm text-stone-400">Loading calendar...</div>'
|
|
||||||
f'</div></details>'
|
|
||||||
)
|
|
||||||
|
|
||||||
if cal_items:
|
|
||||||
browser_html = (
|
|
||||||
'<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3>'
|
|
||||||
+ "".join(cal_items) + '</div>'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
browser_html = '<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3><div class="text-sm text-stone-400">No calendars found.</div></div>'
|
|
||||||
|
|
||||||
return (
|
|
||||||
_raw_html_sx('<div id="post-entries-content" class="space-y-6 p-4">')
|
|
||||||
+ assoc_html
|
|
||||||
+ _raw_html_sx(browser_html + '</div>')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings form
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _h_post_settings_content(slug=None, **kw):
|
async def _h_post_settings_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
from markupsafe import escape as esc
|
|
||||||
from models.ghost_content import Post
|
from models.ghost_content import Post
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
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
|
from bp.post.admin.routes import _post_to_edit_dict
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
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)
|
is_page = p.get("is_page", False)
|
||||||
gp = ghost_post
|
gp = ghost_post
|
||||||
|
|
||||||
def field_label(text, field_for=None):
|
# Extract tag names
|
||||||
for_attr = f' for="{field_for}"' if field_for else ''
|
|
||||||
return f'<label{for_attr} class="block text-[13px] font-medium text-stone-500 mb-[4px]">{esc(text)}</label>'
|
|
||||||
|
|
||||||
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'<input type="{input_type}" name="{name}" id="settings-{name}" value="{esc(value)}"'
|
|
||||||
f' placeholder="{esc(placeholder)}"{ml} class="{input_cls}">')
|
|
||||||
|
|
||||||
def textarea_input(name, value='', placeholder='', rows=3, maxlength=None):
|
|
||||||
ml = f' maxlength="{maxlength}"' if maxlength else ''
|
|
||||||
return (f'<textarea name="{name}" id="settings-{name}" rows="{rows}"'
|
|
||||||
f' placeholder="{esc(placeholder)}"{ml} class="{textarea_cls}">{esc(value)}</textarea>')
|
|
||||||
|
|
||||||
def checkbox_input(name, checked=False, label=''):
|
|
||||||
chk = ' checked' if checked else ''
|
|
||||||
return (f'<label class="inline-flex items-center gap-[8px] cursor-pointer">'
|
|
||||||
f'<input type="checkbox" name="{name}" id="settings-{name}"{chk}'
|
|
||||||
f' class="rounded border-stone-300 text-stone-600 focus:ring-stone-300">'
|
|
||||||
f'<span class="text-[14px] text-stone-600">{esc(label)}</span></label>')
|
|
||||||
|
|
||||||
def section(title, content, is_open=False):
|
|
||||||
open_attr = ' open' if is_open else ''
|
|
||||||
return (f'<details class="border border-stone-200 rounded-[8px] overflow-hidden"{open_attr}>'
|
|
||||||
f'<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">{esc(title)}</summary>'
|
|
||||||
f'<div class="px-[16px] py-[12px] space-y-[12px]">{content}</div></details>')
|
|
||||||
|
|
||||||
# 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'<option value="{v}"{" selected" if vis == v else ""}>{l}</option>'
|
|
||||||
for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")]
|
|
||||||
)
|
|
||||||
|
|
||||||
general = (
|
|
||||||
f'<div>{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}</div>'
|
|
||||||
f'<div>{field_label("Published at", "settings-published_at")}'
|
|
||||||
f'<input type="datetime-local" name="published_at" id="settings-published_at" value="{esc(pub_at_val)}" class="{input_cls}"></div>'
|
|
||||||
f'<div>{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}</div>'
|
|
||||||
f'<div>{field_label("Visibility", "settings-visibility")}'
|
|
||||||
f'<select name="visibility" id="settings-visibility" class="{input_cls}">{vis_opts}</select></div>'
|
|
||||||
f'<div>{checkbox_input("email_only", gp.get("email_only"), "Email only")}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Tags
|
|
||||||
tags = gp.get("tags") or []
|
tags = gp.get("tags") or []
|
||||||
if tags:
|
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:
|
else:
|
||||||
tag_names = ""
|
tag_names = ""
|
||||||
tags_sec = (
|
|
||||||
f'<div>{field_label("Tags (comma-separated)", "settings-tags")}'
|
|
||||||
f'{text_input("tags", tag_names, "news, updates, featured")}'
|
|
||||||
f'<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p></div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
fi_sec = f'<div>{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}</div>'
|
# 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 = (
|
return sx_call("blog-settings-form-content",
|
||||||
f'<div>{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}'
|
csrf=csrf,
|
||||||
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p></div>'
|
updated_at=gp.get("updated_at") or "",
|
||||||
f'<div>{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}'
|
is_page=is_page,
|
||||||
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p></div>'
|
save_success=save_success,
|
||||||
f'<div>{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}</div>'
|
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'<div>{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}</div>'
|
|
||||||
f'<div>{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}</div>'
|
|
||||||
f'<div>{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
tw_sec = (
|
# ---------------------------------------------------------------------------
|
||||||
f'<div>{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}</div>'
|
# Post edit content
|
||||||
f'<div>{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}</div>'
|
# ---------------------------------------------------------------------------
|
||||||
f'<div>{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs'
|
def _extract_newsletter_options(newsletters) -> list:
|
||||||
adv_sec = f'<div>{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}</div>'
|
"""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 = '<span class="text-[14px] text-green-600">Saved.</span>' if save_success else ''
|
def _extract_footer_badges(ghost_post: dict, post: dict, save_success: bool,
|
||||||
|
publish_requested: bool, already_emailed: bool) -> list:
|
||||||
html = (
|
"""Extract footer badge data for .sx rendering."""
|
||||||
f'<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">'
|
badges = []
|
||||||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
if save_success:
|
||||||
f'<input type="hidden" name="updated_at" value="{esc(gp.get("updated_at") or "")}">'
|
badges.append({"cls": "text-[14px] text-green-600", "text": "Saved."})
|
||||||
f'<div class="space-y-[12px] mt-[16px]">{sections}</div>'
|
if publish_requested:
|
||||||
f'<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">'
|
badges.append({"cls": "text-[14px] text-blue-600",
|
||||||
f'<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</button>'
|
"text": "Publish requested \u2014 an admin will review."})
|
||||||
f'{saved_html}</div></form>'
|
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",
|
||||||
return _raw_html_sx(html)
|
"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):
|
async def _h_post_edit_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
import os
|
from quart import g, request as qrequest
|
||||||
from quart import g, request as qrequest, url_for as qurl, current_app
|
|
||||||
from models.ghost_content import Post
|
from models.ghost_content import Post
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from sqlalchemy.orm import selectinload
|
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]
|
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||||
|
|
||||||
csrf = generate_csrf_token()
|
csrf = generate_csrf_token()
|
||||||
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
|
urls = _editor_urls()
|
||||||
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", "")
|
|
||||||
|
|
||||||
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
|
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
|
||||||
is_page = post.get("is_page", False)
|
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 ""
|
sx_content = ghost_post.get("sx_content") or ""
|
||||||
has_sx = bool(sx_content)
|
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")
|
email_obj = ghost_post.get("email")
|
||||||
if email_obj and not isinstance(email_obj, dict):
|
if email_obj and not isinstance(email_obj, dict):
|
||||||
already_emailed = bool(getattr(email_obj, "status", None))
|
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) + ")")
|
nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")")
|
||||||
|
|
||||||
# Footer extra badges as SX fragment
|
# Footer extra badges as SX fragment
|
||||||
badge_parts: list[str] = []
|
publish_requested = bool(qrequest.args.get("publish_requested")) if hasattr(qrequest, 'args') else False
|
||||||
if save_success:
|
badges = _extract_footer_badges(ghost_post, post, save_success,
|
||||||
badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")')
|
publish_requested, already_emailed)
|
||||||
publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
|
if badges:
|
||||||
if publish_requested:
|
badge_parts = [f'(span :class "{b["cls"]}" {sx_serialize(b["text"])})'
|
||||||
badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")')
|
for b in badges]
|
||||||
if post.get("publish_requested"):
|
footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")")
|
||||||
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")')
|
else:
|
||||||
if already_emailed:
|
footer_extra_sx = None
|
||||||
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
|
|
||||||
|
|
||||||
parts: list[str] = []
|
init_js = _editor_init_js(urls, form_id="post-edit-form", has_initial_json=True)
|
||||||
|
|
||||||
if save_error:
|
return sx_call("blog-edit-content",
|
||||||
parts.append(sx_call("blog-editor-error", error=save_error))
|
csrf=csrf, updated_at=str(updated_at),
|
||||||
|
title_val=title_val, excerpt_val=excerpt_val,
|
||||||
parts.append(sx_call("blog-editor-edit-form",
|
|
||||||
csrf=csrf,
|
|
||||||
updated_at=str(updated_at),
|
|
||||||
title_val=title_val,
|
|
||||||
excerpt_val=excerpt_val,
|
|
||||||
feature_image=feature_image,
|
feature_image=feature_image,
|
||||||
feature_image_caption=feature_image_caption,
|
feature_image_caption=feature_image_caption,
|
||||||
sx_content_val=sx_content,
|
sx_content_val=sx_content, lexical_json=lexical_json,
|
||||||
lexical_json=lexical_json,
|
has_sx=has_sx, title_placeholder=title_placeholder,
|
||||||
has_sx=has_sx,
|
status=status, already_emailed=already_emailed,
|
||||||
title_placeholder=title_placeholder,
|
newsletter_options=nl_opts_sx, footer_extra=footer_extra_sx,
|
||||||
status=status,
|
css_href=urls["css_href"], js_src=urls["js_src"],
|
||||||
already_emailed=already_emailed,
|
sx_editor_js_src=urls["sx_editor_js_src"],
|
||||||
newsletter_options=nl_opts_sx,
|
init_js=init_js, save_error=save_error or None)
|
||||||
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) + ")"))
|
|
||||||
|
|||||||
@@ -3,175 +3,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
|
|
||||||
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
|
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."""
|
"""Build the WYSIWYG editor panel for new post/page creation."""
|
||||||
import os
|
|
||||||
from quart import url_for as qurl, current_app
|
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
from shared.sx.helpers import sx_call
|
from shared.sx.helpers import sx_call
|
||||||
|
from .helpers import _editor_urls, _editor_init_js
|
||||||
|
|
||||||
|
urls = _editor_urls()
|
||||||
csrf = generate_csrf_token()
|
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..."
|
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||||
create_label = "Create Page" if is_page else "Create Post"
|
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] = []
|
return sx_call("blog-editor-content",
|
||||||
|
csrf=csrf,
|
||||||
if save_error:
|
title_placeholder=title_placeholder,
|
||||||
parts.append(sx_call("blog-editor-error", error=str(save_error)))
|
|
||||||
|
|
||||||
parts.append(sx_call("blog-editor-form",
|
|
||||||
csrf=csrf, title_placeholder=title_placeholder,
|
|
||||||
create_label=create_label,
|
create_label=create_label,
|
||||||
))
|
css_href=urls["css_href"],
|
||||||
|
js_src=urls["js_src"],
|
||||||
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
|
sx_editor_js_src=urls["sx_editor_js_src"],
|
||||||
parts.append(sx_call("sx-editor-styles"))
|
init_js=init_js,
|
||||||
|
save_error=save_error or None)
|
||||||
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 ""
|
|
||||||
|
|||||||
Reference in New Issue
Block a user