From a8c0741f544ddd6c0f03f21aca8309e2c551dfd0 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 3 Mar 2026 10:23:33 +0000 Subject: [PATCH] Add SX editor to post edit page, prevent sx_content clearing on save - Add sx_content to _post_to_edit_dict so edit page receives existing content - Add SX/Koenig editor tabs, sx-editor mount point, and SxEditor.mount init - Only pass sx_content to writer_update when form field is present (prevents accidental clearing when editing via Koenig-only path) - Add csrf_exempt to example API POST/DELETE/PUT demo endpoints - Add defpage infrastructure (pages.py, layouts.py) and sx docs page definitions - Add defhandler definitions for example API handlers (examples.sx) Co-Authored-By: Claude Opus 4.6 --- blog/bp/post/admin/routes.py | 7 +- blog/sx/sx_components.py | 45 ++++- shared/sx/layouts.py | 139 +++++++++++++ shared/sx/pages.py | 362 ++++++++++++++++++++++++++++++++++ sx/bp/pages/routes.py | 12 ++ sx/sxc/handlers/examples.sx | 367 +++++++++++++++++++++++++++++++++++ sx/sxc/pages/__init__.py | 169 ++++++++++++++++ sx/sxc/pages/docs.sx | 98 ++++++++++ 8 files changed, 1194 insertions(+), 5 deletions(-) create mode 100644 shared/sx/layouts.py create mode 100644 shared/sx/pages.py create mode 100644 sx/sxc/handlers/examples.sx create mode 100644 sx/sxc/pages/__init__.py create mode 100644 sx/sxc/pages/docs.sx diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index abec2d5..a4b86f2 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -23,6 +23,7 @@ def _post_to_edit_dict(post) -> dict: d: dict = {} for col in ( "id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc", + "sx_content", "feature_image", "feature_image_alt", "feature_image_caption", "excerpt", "custom_excerpt", "visibility", "status", "featured", "is_page", "email_only", "canonical_url", @@ -595,6 +596,10 @@ def register(): effective_status = status sx_content_raw = form.get("sx_content", "").strip() or None + # Build optional kwargs — only pass sx_content if the form field was present + extra_kw: dict = {} + if "sx_content" in form: + extra_kw["sx_content"] = sx_content_raw try: post = await writer_update( g.s, @@ -606,7 +611,7 @@ def register(): custom_excerpt=custom_excerpt or None, feature_image_caption=feature_image_caption or None, status=effective_status, - sx_content=sx_content_raw, + **extra_kw, ) except OptimisticLockError: return redirect( diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py index 602d08d..b4e49c2 100644 --- a/blog/sx/sx_components.py +++ b/blog/sx/sx_components.py @@ -1755,6 +1755,7 @@ def _post_edit_content_sx(ctx: dict) -> str: 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") @@ -1773,6 +1774,7 @@ def _post_edit_content_sx(ctx: dict) -> str: updated_at = esc(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 "" 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 @@ -1799,6 +1801,7 @@ def _post_edit_content_sx(ctx: dict) -> str: form_parts.append(f'') form_parts.append(f'') form_parts.append('') + form_parts.append(f'') form_parts.append(f'') form_parts.append(f'') @@ -1830,8 +1833,24 @@ def _post_edit_content_sx(ctx: dict) -> str: f' class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed">{excerpt_val}' ) - # Editor mount point - form_parts.append('
') + # 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'') @@ -1902,8 +1921,10 @@ def _post_edit_content_sx(ctx: dict) -> str: '' ) - # Editor CSS + styles + # 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( '' ) - # Editor JS + init + # Initial sx content for SX editor + 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( '