diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py
index 5bc726d..abec2d5 100644
--- a/blog/bp/post/admin/routes.py
+++ b/blog/bp/post/admin/routes.py
@@ -2,7 +2,6 @@ from __future__ import annotations
from quart import (
- render_template,
make_response,
Blueprint,
g,
@@ -190,9 +189,7 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_post_data_page, render_post_data_oob
- data_html = await render_template("_types/post_data/_main_panel.html")
tctx = await get_template_context()
- tctx["data_html"] = data_html
if not is_htmx_request():
html = await render_post_data_page(tctx)
return await make_response(html)
@@ -323,24 +320,14 @@ def register():
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
- html = await render_template(
- "_types/post/admin/_calendar_view.html",
- calendar=calendar_obj,
- year=year,
- month=month,
- month_name=month_name,
- weekday_names=weekday_names,
- weeks=weeks,
- prev_month=prev_month,
- prev_month_year=prev_month_year,
- next_month=next_month,
- next_month_year=next_month_year,
- prev_year=prev_year,
- next_year=next_year,
- month_entries=month_entries,
- associated_entry_ids=associated_entry_ids,
+ from sx.sx_components import render_calendar_view
+ html = render_calendar_view(
+ calendar_obj, year, month, month_name, weekday_names, weeks,
+ prev_month, prev_month_year, next_month, next_month_year,
+ prev_year, next_year, month_entries, associated_entry_ids,
+ g.post_data["post"]["slug"],
)
- return await make_response(html)
+ return sx_response(html)
@bp.get("/entries/")
@require_admin
@@ -366,13 +353,9 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_post_entries_page, render_post_entries_oob
- entries_html = await render_template(
- "_types/post_entries/_main_panel.html",
- all_calendars=all_calendars,
- associated_entry_ids=associated_entry_ids,
- )
tctx = await get_template_context()
- tctx["entries_html"] = entries_html
+ tctx["all_calendars"] = all_calendars
+ tctx["associated_entry_ids"] = associated_entry_ids
if not is_htmx_request():
html = await render_post_entries_page(tctx)
return await make_response(html)
@@ -452,13 +435,9 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_post_settings_page, render_post_settings_oob
- settings_html = await render_template(
- "_types/post_settings/_main_panel.html",
- ghost_post=ghost_post,
- save_success=save_success,
- )
tctx = await get_template_context()
- tctx["settings_html"] = settings_html
+ tctx["ghost_post"] = ghost_post
+ tctx["save_success"] = save_success
if not is_htmx_request():
html = await render_post_settings_page(tctx)
return await make_response(html)
@@ -560,15 +539,11 @@ def register():
from shared.sx.page import get_template_context
from sx.sx_components import render_post_edit_page, render_post_edit_oob
- edit_html = await render_template(
- "_types/post_edit/_main_panel.html",
- ghost_post=ghost_post,
- save_success=save_success,
- save_error=save_error,
- newsletters=newsletters,
- )
tctx = await get_template_context()
- tctx["edit_html"] = edit_html
+ tctx["ghost_post"] = ghost_post
+ tctx["save_success"] = save_success
+ tctx["save_error"] = save_error
+ tctx["newsletters"] = newsletters
if not is_htmx_request():
html = await render_post_edit_page(tctx)
return await make_response(html)
diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py
index f5a8693..602d08d 100644
--- a/blog/sx/sx_components.py
+++ b/blog/sx/sx_components.py
@@ -1362,18 +1362,134 @@ async def render_post_admin_oob(ctx: dict) -> str:
# ---- Post data ----
+def _post_data_content_sx(ctx: dict) -> str:
+ """Build post data inspector panel natively (replaces _types/post_data/_main_panel.html)."""
+ from markupsafe import escape as esc
+ from quart import g
+
+ original_post = getattr(g, "post_data", {}).get("original_post")
+ if original_post is None:
+ return _raw_html_sx('
No post data available.
')
+
+ tablename = getattr(original_post, "__tablename__", "?")
+
+ def _render_scalar_table(obj):
+ rows = []
+ for col in obj.__mapper__.columns:
+ key = col.key
+ if key == "_sa_instance_state":
+ continue
+ val = getattr(obj, key, None)
+ if val is None:
+ val_html = '\u2014 '
+ elif hasattr(val, "isoformat"):
+ val_html = f'{esc(val.isoformat())} '
+ elif isinstance(val, str):
+ val_html = f'{esc(val)} '
+ else:
+ val_html = f'{esc(str(val))} '
+ rows.append(
+ f''
+ f'{esc(key)} '
+ f'{val_html} '
+ )
+ return (
+ ''
+ '
'
+ ''
+ 'Field '
+ 'Value '
+ ' ' + "".join(rows) + '
'
+ )
+
+ def _render_model(obj, depth=0, max_depth=2):
+ parts = [_render_scalar_table(obj)]
+ rel_parts = []
+ for rel in obj.__mapper__.relationships:
+ rel_name = rel.key
+ loaded = rel_name in obj.__dict__
+ value = getattr(obj, rel_name, None) if loaded else None
+ cardinality = "many" if rel.uselist else "one"
+ cls_name = rel.mapper.class_.__name__
+ loaded_label = "" if loaded else " \u2022 not loaded "
+
+ inner = ""
+ if value is None:
+ inner = '\u2014 '
+ elif rel.uselist:
+ items = list(value) if value else []
+ inner = f'{len(items)} item{"" if len(items) == 1 else "s"}
'
+ if items and depth < max_depth:
+ sub_rows = []
+ for i, it in enumerate(items, 1):
+ ident_parts = []
+ for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
+ if k in it.__mapper__.c:
+ v = getattr(it, k, "")
+ ident_parts.append(f"{k}={v}")
+ summary = " \u2022 ".join(ident_parts) if ident_parts else str(it)
+ child_html = ""
+ if depth < max_depth:
+ child_html = f'{_render_model(it, depth + 1, max_depth)}
'
+ else:
+ child_html = '\u2026max depth reached\u2026
'
+ sub_rows.append(
+ f''
+ f'{i} '
+ f'{esc(summary)} {child_html} '
+ )
+ inner += (
+ ''
+ '
'
+ '# '
+ 'Summary '
+ + "".join(sub_rows) + '
'
+ )
+ else:
+ child = value
+ ident_parts = []
+ for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
+ if k in child.__mapper__.c:
+ v = getattr(child, k, "")
+ ident_parts.append(f"{k}={v}")
+ summary = " \u2022 ".join(ident_parts) if ident_parts else str(child)
+ inner = f'{esc(summary)} '
+ if depth < max_depth:
+ inner += f'{_render_model(child, depth + 1, max_depth)}
'
+ else:
+ inner += '\u2026max depth reached\u2026
'
+
+ rel_parts.append(
+ f''
+ f'
'
+ f'Relationship: {esc(rel_name)} '
+ f' {cardinality} \u2192 {esc(cls_name)}{loaded_label}
'
+ f'
{inner}
'
+ )
+ if rel_parts:
+ parts.append('' + "".join(rel_parts) + '
')
+ return '' + "".join(parts) + '
'
+
+ html = (
+ f''
+ f'
Model: Post \u2022 Table: {esc(tablename)}
'
+ f'{_render_model(original_post, 0, 2)}
'
+ )
+ return _raw_html_sx(html)
+
+
async def render_post_data_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="data")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
- content = _raw_html_sx(ctx.get("data_html", ""))
+ content = _post_data_content_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_data_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="data")
- content = _raw_html_sx(ctx.get("data_html", ""))
+ content = _post_data_content_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)
@@ -1441,21 +1557,180 @@ async def render_post_preview_oob(ctx: dict) -> str:
# ---- Post entries ----
+def _post_entries_content_sx(ctx: dict) -> str:
+ """Build post entries panel natively (replaces _types/post_entries/_main_panel.html)."""
+ from quart import g, url_for as qurl
+ from shared.utils import host_url
+
+ all_calendars = ctx.get("all_calendars", [])
+ associated_entry_ids = ctx.get("associated_entry_ids", set())
+ post_slug = g.post_data["post"]["slug"]
+
+ # Associated entries list (reuse existing render function)
+ assoc_html = render_associated_entries(all_calendars, associated_entry_ids, post_slug)
+
+ # Calendar browser
+ cal_items: list[str] = []
+ for cal in all_calendars:
+ cal_post = getattr(cal, "post", None)
+ cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
+ cal_title = escape(getattr(cal_post, "title", "")) if cal_post else ""
+ cal_name = escape(getattr(cal, "name", ""))
+ cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id))
+
+ img_html = (
+ f' '
+ if cal_fi else
+ '
'
+ )
+ cal_items.append(
+ f''
+ f''
+ f'{img_html}'
+ f''
+ f'
{cal_name}
'
+ f'
{cal_title}
'
+ f'
'
+ f''
+ f'
Loading calendar...
'
+ f'
'
+ )
+
+ if cal_items:
+ browser_html = (
+ '
Browse Calendars '
+ + "".join(cal_items) + ''
+ )
+ else:
+ browser_html = 'Browse Calendars No calendars found.
'
+
+ # assoc_html is sx (from render_associated_entries); browser is raw HTML
+ # Wrap the whole thing: open div as raw, then associated entries (sx), then browser (raw), close div
+ return (
+ _raw_html_sx('')
+ + assoc_html
+ + _raw_html_sx(browser_html + '
')
+ )
+
+
async def render_post_entries_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="entries")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
- content = _raw_html_sx(ctx.get("entries_html", ""))
+ content = _post_entries_content_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_entries_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="entries")
- content = _raw_html_sx(ctx.get("entries_html", ""))
+ content = _post_entries_content_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)
+# ---- Calendar view (for entries browser) ----
+
+def render_calendar_view(
+ calendar, year, month, month_name, weekday_names, weeks,
+ prev_month, prev_month_year, next_month, next_month_year,
+ prev_year, next_year, month_entries, associated_entry_ids,
+ post_slug: str,
+) -> str:
+ """Build calendar month grid HTML (replaces _types/post/admin/_calendar_view.html)."""
+ from quart import url_for as qurl
+ from shared.browser.app.csrf import generate_csrf_token
+ from shared.utils import host_url
+ esc = escape
+
+ csrf = generate_csrf_token()
+ cal_id = calendar.id
+
+ def cal_url(y, m):
+ return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
+
+ cur_url = cal_url(year, month)
+ toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
+
+ # Navigation header
+ nav = (
+ f''
+ f''
+ f'« '
+ f'‹ '
+ f'{esc(month_name)} {year}
'
+ f'› '
+ f'» '
+ f' '
+ )
+
+ # Weekday header
+ wd_cells = "".join(f'{esc(wd)}
' for wd in weekday_names)
+ wd_row = f'{wd_cells}
'
+
+ # Grid cells
+ cells: list[str] = []
+ for week in weeks:
+ for day in week:
+ extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
+ day_date = day.date
+
+ entry_btns: list[str] = []
+ for e in month_entries:
+ e_start = getattr(e, "start_at", None)
+ if not e_start or e_start.date() != day_date:
+ continue
+ e_id = getattr(e, "id", None)
+ e_name = esc(getattr(e, "name", ""))
+ t_url = toggle_url_fn(e_id)
+ hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
+
+ if e_id in associated_entry_ids:
+ entry_btns.append(
+ f''
+ f'{e_name} '
+ f'
'
+ )
+ else:
+ entry_btns.append(
+ f'{e_name} '
+ )
+
+ entries_html = '' + "".join(entry_btns) + '
' if entry_btns else ''
+ cells.append(
+ f''
+ )
+
+ grid = f'{"".join(cells)}
'
+
+ html = (
+ f''
+ f'{nav}'
+ f'
{wd_row}{grid}
'
+ f'
'
+ )
+ return _raw_html_sx(html)
+
+
# ---- Post edit ----
def _raw_html_sx(html: str) -> str:
@@ -1465,35 +1740,436 @@ def _raw_html_sx(html: str) -> str:
return "(raw! " + sx_serialize(html) + ")"
+def _post_edit_content_sx(ctx: dict) -> str:
+ """Build WYSIWYG editor panel natively (replaces _types/post_edit/_main_panel.html)."""
+ from quart import url_for as qurl, current_app, g, request as qrequest
+ from shared.browser.app.csrf import generate_csrf_token
+ esc = escape
+
+ ghost_post = ctx.get("ghost_post", {}) or {}
+ save_success = ctx.get("save_success", False)
+ save_error = ctx.get("save_error", "")
+ newsletters = ctx.get("newsletters", [])
+
+ csrf = generate_csrf_token()
+ asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
+ editor_css = asset_url_fn("scripts/editor.css")
+ editor_js = asset_url_fn("scripts/editor.js")
+
+ 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 {}
+ is_page = post.get("is_page", False)
+
+ 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 "")
+ 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}}'
+
+ 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' ')
+
+ # Feature image section
+ form_parts.append(
+ f''
+ f'
'
+ f'+ Add feature image '
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ f'
Uploading...
'
+ f'
'
+ f'
'
+ )
+
+ # Title
+ form_parts.append(
+ f' '
+ )
+
+ # Excerpt
+ form_parts.append(
+ f''
+ )
+
+ # Editor mount point
+ form_parts.append('
')
+
+ # 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 = 'Select newsletter\u2026 '
+ for nl in newsletters:
+ nl_slug = esc(getattr(nl, "slug", ""))
+ nl_name = esc(getattr(nl, "name", ""))
+ nl_options += f'{nl_name} '
+
+ footer_extra = ''
+ if save_success:
+ footer_extra += ' 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. '
+ if post.get("publish_requested"):
+ footer_extra += ' 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} '
+
+ form_parts.append(
+ f''
+ f''
+ f'Draft '
+ f'Published '
+ f''
+ f'Web only Email only Web + Email '
+ f'{nl_options} '
+ f'Save '
+ f'{footer_extra}
'
+ )
+
+ form_html = ''
+ parts.append(form_html)
+
+ # Publish-mode show/hide JS
+ already_emailed_js = 'true' if already_emailed else 'false'
+ parts.append(
+ ''
+ )
+
+ # Editor CSS + styles
+ parts.append(f' ')
+ parts.append(
+ ''
+ )
+
+ # Editor JS + init
+ parts.append(f'')
+ parts.append(
+ ''
+ )
+
+ return _raw_html_sx("".join(parts))
+
+
async def render_post_edit_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="edit")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
- content = _raw_html_sx(ctx.get("edit_html", ""))
+ content = _post_edit_content_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_edit_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="edit")
- content = _raw_html_sx(ctx.get("edit_html", ""))
+ content = _post_edit_content_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Post settings ----
+def _post_settings_content_sx(ctx: dict) -> str:
+ """Build settings form natively (replaces _types/post_settings/_main_panel.html)."""
+ from quart import g
+ from shared.browser.app.csrf import generate_csrf_token
+ esc = escape
+
+ ghost_post = ctx.get("ghost_post", {}) or {}
+ save_success = ctx.get("save_success", False)
+ csrf = generate_csrf_token()
+
+ post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
+ is_page = post.get("is_page", False)
+
+ def field_label(text, field_for=None):
+ for_attr = f' for="{field_for}"' if field_for else ''
+ return f'{esc(text)} '
+
+ input_cls = ('w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] '
+ 'bg-white text-stone-700 placeholder:text-stone-300 '
+ 'focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300')
+ textarea_cls = input_cls + ' resize-y'
+
+ def text_input(name, value='', placeholder='', input_type='text', maxlength=None):
+ ml = f' maxlength="{maxlength}"' if maxlength else ''
+ return (f' ')
+
+ def textarea_input(name, value='', placeholder='', rows=3, maxlength=None):
+ ml = f' maxlength="{maxlength}"' if maxlength else ''
+ return (f'')
+
+ def checkbox_input(name, checked=False, label=''):
+ chk = ' checked' if checked else ''
+ return (f''
+ f' '
+ f'{esc(label)} ')
+
+ def section(title, content, is_open=False):
+ open_attr = ' open' if is_open else ''
+ return (f''
+ f'{esc(title)} '
+ f'{content}
')
+
+ gp = ghost_post
+
+ # 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'{l} '
+ for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")]
+ )
+
+ general = (
+ f'{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}
'
+ f'{field_label("Published at", "settings-published_at")}'
+ f'
'
+ f'{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}
'
+ f'{field_label("Visibility", "settings-visibility")}'
+ f'{vis_opts}
'
+ f'{checkbox_input("email_only", gp.get("email_only"), "Email only")}
'
+ )
+
+ # Tags
+ tags = gp.get("tags") or []
+ if tags:
+ tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags)
+ else:
+ tag_names = ""
+ tags_sec = (
+ f'{field_label("Tags (comma-separated)", "settings-tags")}'
+ f'{text_input("tags", tag_names, "news, updates, featured")}'
+ f'
Unknown tags will be created automatically.
'
+ )
+
+ # Feature image
+ fi_sec = f'{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}
'
+
+ # SEO
+ seo_sec = (
+ f'{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}'
+ f'
Recommended: 70 characters. Max: 300.
'
+ f'{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}'
+ f'
Recommended: 156 characters.
'
+ f'{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}
'
+ )
+
+ # Facebook / OG
+ og_sec = (
+ f'{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}
'
+ f'{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}
'
+ f'{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}
'
+ )
+
+ # Twitter
+ tw_sec = (
+ f'{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}
'
+ f'{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}
'
+ f'{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}
'
+ )
+
+ # Advanced
+ tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs'
+ adv_sec = f'{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}
'
+
+ sections = (
+ section("General", general, is_open=True)
+ + section("Tags", tags_sec)
+ + section("Feature Image", fi_sec)
+ + section("SEO / Meta", seo_sec)
+ + section("Facebook / OpenGraph", og_sec)
+ + section("X / Twitter", tw_sec)
+ + section("Advanced", adv_sec)
+ )
+
+ saved_html = 'Saved. ' if save_success else ''
+
+ html = (
+ f''
+ )
+ return _raw_html_sx(html)
+
+
async def render_post_settings_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="settings")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
- content = _raw_html_sx(ctx.get("settings_html", ""))
+ content = _post_settings_content_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_settings_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="settings")
- content = _raw_html_sx(ctx.get("settings_html", ""))
+ content = _post_settings_content_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)