diff --git a/blog/sx/editor.sx b/blog/sx/editor.sx index e98c2f7..a55b0ca 100644 --- a/blog/sx/editor.sx +++ b/blog/sx/editor.sx @@ -55,6 +55,108 @@ (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" create-label)))) +;; Edit form — pre-populated version for //admin/edit/ +(defcomp ~blog-editor-edit-form (&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) + (let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600") + (active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent") + (inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600")) + (form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "updated_at" :value updated-at) + (input :type "hidden" :id "lexical-json-input" :name "lexical" :value "") + (input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val "")) + (input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image "")) + (input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption "")) + (div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group" + (div :id "feature-image-empty" :class (if feature-image "hidden" "") + (button :type "button" :id "feature-image-add-btn" + :class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer" + "+ Add feature image")) + (div :id "feature-image-filled" :class (str "relative " (if feature-image "" "hidden")) + (img :id "feature-image-preview" :src (or feature-image "") :alt "" + :class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer") + (button :type "button" :id "feature-image-delete-btn" + :class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]" + :title "Remove feature image" + (i :class "fa-solid fa-trash-can")) + (input :type "text" :id "feature-image-caption" :value (or feature-image-caption "") + :placeholder "Add a caption..." + :class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700")) + (div :id "feature-image-uploading" + :class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400" + (i :class "fa-solid fa-spinner fa-spin") " Uploading...") + (input :type "file" :id "feature-image-file" + :accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden")) + (input :type "text" :name "title" :value (or title-val "") :placeholder title-placeholder + :class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight") + (textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..." + :class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed" + (or excerpt-val "")) + ;; Editor tabs + (div :class "flex gap-[4px] mb-[8px] border-b border-stone-200" + (button :type "button" :id "editor-tab-sx" + :class (if has-sx active inactive) + :onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'" + "SX Editor") + (button :type "button" :id "editor-tab-koenig" + :class (if has-sx inactive active) + :onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'" + "Koenig (Legacy)")) + (div :id "sx-editor" :class "relative w-full bg-transparent" + :style (if has-sx "" "display:none")) + (div :id "lexical-editor" :class "relative w-full bg-transparent" + :style (if has-sx "display:none" "")) + ;; Initial lexical JSON + (script :id "lexical-initial-data" :type "application/json" lexical-json) + ;; Footer: status + publish mode + newsletter + save + badges + (div :class "flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200" + (select :id "status-select" :name "status" :class sel-cls + (option :value "draft" :selected (= status "draft") "Draft") + (option :value "published" :selected (= status "published") "Published")) + (select :id "publish-mode-select" :name "publish_mode" + :class (str sel-cls (if (= status "published") "" " hidden") + (if already-emailed " opacity-50 pointer-events-none" "")) + :disabled (if already-emailed true nil) + (option :value "web" :selected true "Web only") + (option :value "email" "Email only") + (option :value "both" "Web + Email")) + (select :id "newsletter-select" :name "newsletter_slug" + :class (str sel-cls " hidden") + :disabled (if already-emailed true nil) + newsletter-options) + (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") + (when footer-extra footer-extra))))) + +;; Publish-mode show/hide script for edit form +(defcomp ~blog-editor-publish-js (&key already-emailed) + (script + "(function() {" + " var statusSel = document.getElementById('status-select');" + " var modeSel = document.getElementById('publish-mode-select');" + " var nlSel = document.getElementById('newsletter-select');" + (str " var alreadyEmailed = " (if already-emailed "true" "false") ";") + " function sync() {" + " var isPublished = statusSel.value === 'published';" + " if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }" + " var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');" + " if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }" + " }" + " statusSel.addEventListener('change', sync);" + " modeSel.addEventListener('change', sync);" + " sync();" + "})();")) + +;; SX initial content script for edit form +(defcomp ~blog-editor-sx-initial (&key sx-content) + (script (str "window.__SX_INITIAL__ = '" sx-content "' || null;"))) + (defcomp ~blog-editor-styles (&key css-href) (<> (link :rel "stylesheet" :href css-href) (style diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py index 01955ab..50e1a55 100644 --- a/blog/sx/sx_components.py +++ b/blog/sx/sx_components.py @@ -1678,10 +1678,11 @@ def _raw_html_sx(html: str) -> str: def _post_edit_content_sx(ctx: dict) -> str: - """Build WYSIWYG editor panel natively (replaces _types/post_edit/_main_panel.html).""" + """Build WYSIWYG editor panel as SX expression (edit page).""" from quart import url_for as qurl, current_app, g, request as qrequest from shared.browser.app.csrf import generate_csrf_token - esc = escape + from shared.sx.helpers import sx_call + from shared.sx.parser import SxExpr ghost_post = ctx.get("ghost_post", {}) or {} save_success = ctx.get("save_success", False) @@ -1706,181 +1707,87 @@ def _post_edit_content_sx(ctx: dict) -> str: feature_image = ghost_post.get("feature_image") or "" feature_image_caption = ghost_post.get("feature_image_caption") or "" - title_val = esc(ghost_post.get("title") or "") - excerpt_val = esc(ghost_post.get("custom_excerpt") or "") - updated_at = esc(ghost_post.get("updated_at") or "") + title_val = ghost_post.get("title") or "" + excerpt_val = ghost_post.get("custom_excerpt") or "" + updated_at = ghost_post.get("updated_at") or "" status = ghost_post.get("status") or "draft" lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' 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")) - # For ORM objects the email may be an object email_obj = ghost_post.get("email") if email_obj and not isinstance(email_obj, dict): already_emailed = bool(getattr(email_obj, "status", None)) - parts: list[str] = [] - - # Error banner - if save_error: - parts.append( - f'
' - f'Save failed: {esc(save_error)}
' - ) - - # Hidden inputs - fi_hidden = f' hidden' if not feature_image else '' - fi_visible = f' hidden' if feature_image else '' - title_placeholder = "Page title..." if is_page else "Post title..." - form_parts: list[str] = [] - form_parts.append(f'') - form_parts.append(f'') - form_parts.append('') - form_parts.append(f'') - form_parts.append(f'') - form_parts.append(f'') - - # Feature image section - form_parts.append( - f'
' - f'
' - f'' - f'
' - f'
' - f'' - f'' - f'' - f'
' - f'' - f'' - f'
' - ) - - # Title - form_parts.append( - f'' - ) - - # Excerpt - form_parts.append( - f'' - ) - - # Editor tabs: SX (primary) and Koenig (legacy) - has_sx = bool(sx_content) - sx_active = 'text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent' - sx_inactive = 'text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600' - form_parts.append( - '
' - f'' - f'' - '
' - ) - # SX editor mount point - form_parts.append(f'
') - # Koenig editor mount point - form_parts.append(f'
') - - # Initial lexical JSON - form_parts.append(f'') - - # Status + publish footer - draft_sel = ' selected' if status == 'draft' else '' - pub_sel = ' selected' if status == 'published' else '' - mode_hidden = ' hidden' if status != 'published' else '' - mode_disabled = ' opacity-50 pointer-events-none' if already_emailed else '' - mode_dis_attr = ' disabled' if already_emailed else '' - - nl_options = '' + # Newsletter options as SX fragment + nl_parts = ['(option :value "" "Select newsletter\u2026")'] for nl in newsletters: - nl_slug = esc(getattr(nl, "slug", "")) - nl_name = esc(getattr(nl, "name", "")) - nl_options += f'' + nl_slug = sx_serialize(getattr(nl, "slug", "")) + nl_name = sx_serialize(getattr(nl, "name", "")) + nl_parts.append(f"(option :value {nl_slug} {nl_name})") + nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")") - footer_extra = '' + # Footer extra badges as SX fragment + badge_parts: list[str] = [] if save_success: - footer_extra += ' Saved.' + 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: - footer_extra += ' Publish requested \u2014 an admin will review.' + badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")') if post.get("publish_requested"): - footer_extra += ' 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 {esc(nl_name)}" if nl_name else "" - footer_extra += f' Emailed{suffix}' + 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 - form_parts.append( - f'
' - f'' - f'' - f'' - f'' - f'{footer_extra}
' - ) - - form_html = '
' + "".join(form_parts) + '
' - parts.append(form_html) - - # Publish-mode show/hide JS - already_emailed_js = 'true' if already_emailed else 'false' - parts.append( - '' - ) - - # Editor CSS + styles + SX editor styles - from shared.sx.helpers import sx_call - parts.append(f'') - parts.append(sx_call("sx-editor-styles")) - parts.append( - '' - ) - - # Initial sx content for SX editor + # Escape sx_content for JS string literal sx_initial_escaped = sx_content.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n').replace('\r', '') - parts.append(f"") - # Editor JS + SX editor JS + init - parts.append(f'') - parts.append(f'') - parts.append( - '' ) + parts.append(sx_call("blog-editor-scripts", + js_src=editor_js, + sx_editor_js_src=sx_editor_js, + init_js=init_js)) - return _raw_html_sx("".join(parts)) + return "(<> " + " ".join(parts) + ")" # =========================================================================== diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 553153d..0f044d4 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -378,6 +378,7 @@ def sx_call(component_name: str, **kwargs: Any) -> str: return "(" + " ".join(parts) + ")" + def components_for_request() -> str: """Return defcomp/defmacro source for definitions the client doesn't have yet.