Replace 5 blog post admin render_template() calls with native sx builders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m49s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m49s
Converts data inspector, entries browser, calendar view, settings form, and WYSIWYG editor panels from Jinja templates to Python content builders. Zero render_template() calls remain across blog, events, and orders services. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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('<div class="px-4 py-8 text-stone-400">No post data available.</div>')
|
||||
|
||||
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 = '<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)
|
||||
|
||||
|
||||
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'<img src="{escape(cal_fi)}" alt="{cal_title}" class="w-12 h-12 rounded object-cover flex-shrink-0" />'
|
||||
if cal_fi else
|
||||
'<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="{escape(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>'
|
||||
|
||||
# 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('<div id="post-entries-content" class="space-y-6 p-4">')
|
||||
+ assoc_html
|
||||
+ _raw_html_sx(browser_html + '</div>')
|
||||
)
|
||||
|
||||
|
||||
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'<header class="flex items-center justify-center mb-4">'
|
||||
f'<nav class="flex items-center gap-2 text-xl">'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">«</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">‹</a>'
|
||||
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">›</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">»</a>'
|
||||
f'</nav></header>'
|
||||
)
|
||||
|
||||
# Weekday header
|
||||
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
|
||||
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
|
||||
|
||||
# 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'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
|
||||
f'<span class="truncate flex-1">{e_name}</span>'
|
||||
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
|
||||
f' data-confirm data-confirm-title="Remove entry?"'
|
||||
f' data-confirm-text="Remove {e_name} from this post?"'
|
||||
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><i class="fa fa-times"></i></button></div>'
|
||||
)
|
||||
else:
|
||||
entry_btns.append(
|
||||
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
|
||||
f' data-confirm data-confirm-title="Add entry?"'
|
||||
f' data-confirm-text="Add {e_name} to this post?"'
|
||||
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><span class="truncate block">{e_name}</span></button>'
|
||||
)
|
||||
|
||||
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
|
||||
cells.append(
|
||||
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
|
||||
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
|
||||
)
|
||||
|
||||
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
|
||||
|
||||
html = (
|
||||
f'<div id="calendar-view-{cal_id}"'
|
||||
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
|
||||
f'{nav}'
|
||||
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
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'<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">'
|
||||
f'<strong>Save failed:</strong> {esc(save_error)}</div>'
|
||||
)
|
||||
|
||||
# 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'<input type="hidden" name="csrf_token" value="{csrf}">')
|
||||
form_parts.append(f'<input type="hidden" name="updated_at" value="{updated_at}">')
|
||||
form_parts.append('<input type="hidden" id="lexical-json-input" name="lexical" value="">')
|
||||
form_parts.append(f'<input type="hidden" id="feature-image-input" name="feature_image" value="{esc(feature_image)}">')
|
||||
form_parts.append(f'<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="{esc(feature_image_caption)}">')
|
||||
|
||||
# Feature image section
|
||||
form_parts.append(
|
||||
f'<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">'
|
||||
f'<div id="feature-image-empty" class="{"hidden" if feature_image else ""}">'
|
||||
f'<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</button>'
|
||||
f'</div>'
|
||||
f'<div id="feature-image-filled" class="relative {"" if feature_image else "hidden"}">'
|
||||
f'<img id="feature-image-preview" src="{esc(feature_image)}" alt="" class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer">'
|
||||
f'<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"></i></button>'
|
||||
f'<input type="text" id="feature-image-caption" value="{esc(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">'
|
||||
f'</div>'
|
||||
f'<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"></i> Uploading...</div>'
|
||||
f'<input type="file" id="feature-image-file" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml" class="hidden">'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
# Title
|
||||
form_parts.append(
|
||||
f'<input type="text" name="title" value="{title_val}" placeholder="{title_placeholder}"'
|
||||
f' class="w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight">'
|
||||
)
|
||||
|
||||
# Excerpt
|
||||
form_parts.append(
|
||||
f'<textarea name="custom_excerpt" rows="1" placeholder="Add an excerpt..."'
|
||||
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}</textarea>'
|
||||
)
|
||||
|
||||
# Editor mount point
|
||||
form_parts.append('<div id="lexical-editor" class="relative w-full bg-transparent"></div>')
|
||||
|
||||
# Initial lexical JSON
|
||||
form_parts.append(f'<script id="lexical-initial-data" type="application/json">{lexical_json}</script>')
|
||||
|
||||
# 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 = '<option value="">Select newsletter\u2026</option>'
|
||||
for nl in newsletters:
|
||||
nl_slug = esc(getattr(nl, "slug", ""))
|
||||
nl_name = esc(getattr(nl, "name", ""))
|
||||
nl_options += f'<option value="{nl_slug}">{nl_name}</option>'
|
||||
|
||||
footer_extra = ''
|
||||
if save_success:
|
||||
footer_extra += ' <span class="text-[14px] text-green-600">Saved.</span>'
|
||||
publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
|
||||
if publish_requested:
|
||||
footer_extra += ' <span class="text-[14px] text-blue-600">Publish requested \u2014 an admin will review.</span>'
|
||||
if post.get("publish_requested"):
|
||||
footer_extra += ' <span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>'
|
||||
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' <span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">Emailed{suffix}</span>'
|
||||
|
||||
form_parts.append(
|
||||
f'<div class="flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">'
|
||||
f'<select id="status-select" name="status" class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600">'
|
||||
f'<option value="draft"{draft_sel}>Draft</option>'
|
||||
f'<option value="published"{pub_sel}>Published</option></select>'
|
||||
f'<select id="publish-mode-select" name="publish_mode" class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600{mode_hidden}{mode_disabled}"{mode_dis_attr}>'
|
||||
f'<option value="web" selected>Web only</option><option value="email">Email only</option><option value="both">Web + Email</option></select>'
|
||||
f'<select id="newsletter-select" name="newsletter_slug" class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600 hidden"{mode_dis_attr}>{nl_options}</select>'
|
||||
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</button>'
|
||||
f'{footer_extra}</div>'
|
||||
)
|
||||
|
||||
form_html = '<form id="post-edit-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">' + "".join(form_parts) + '</form>'
|
||||
parts.append(form_html)
|
||||
|
||||
# Publish-mode show/hide JS
|
||||
already_emailed_js = 'true' if already_emailed else 'false'
|
||||
parts.append(
|
||||
'<script>'
|
||||
'(function() {'
|
||||
" var statusSel = document.getElementById('status-select');"
|
||||
" var modeSel = document.getElementById('publish-mode-select');"
|
||||
" var nlSel = document.getElementById('newsletter-select');"
|
||||
f' var alreadyEmailed = {already_emailed_js};'
|
||||
' 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();'
|
||||
'})();'
|
||||
'</script>'
|
||||
)
|
||||
|
||||
# Editor CSS + styles
|
||||
parts.append(f'<link rel="stylesheet" href="{esc(editor_css)}">')
|
||||
parts.append(
|
||||
'<style>'
|
||||
'#lexical-editor { display: flow-root; }'
|
||||
'#lexical-editor [data-kg-card="html"] * { float: none !important; }'
|
||||
'#lexical-editor [data-kg-card="html"] table { width: 100% !important; }'
|
||||
'</style>'
|
||||
)
|
||||
|
||||
# Editor JS + init
|
||||
parts.append(f'<script src="{esc(editor_js)}"></script>')
|
||||
parts.append(
|
||||
'<script>'
|
||||
'(function() {'
|
||||
# Font size overrides for Koenig
|
||||
" 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}',"
|
||||
' });'
|
||||
" 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); }'
|
||||
'})();'
|
||||
'</script>'
|
||||
)
|
||||
|
||||
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'<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>')
|
||||
|
||||
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'<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 []
|
||||
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'<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>'
|
||||
)
|
||||
|
||||
# Feature image
|
||||
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>'
|
||||
|
||||
# SEO
|
||||
seo_sec = (
|
||||
f'<div>{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}'
|
||||
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p></div>'
|
||||
f'<div>{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}'
|
||||
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p></div>'
|
||||
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>'
|
||||
)
|
||||
|
||||
# Facebook / OG
|
||||
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>'
|
||||
)
|
||||
|
||||
# Twitter
|
||||
tw_sec = (
|
||||
f'<div>{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}</div>'
|
||||
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>'
|
||||
)
|
||||
|
||||
# Advanced
|
||||
tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs'
|
||||
adv_sec = f'<div>{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}</div>'
|
||||
|
||||
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 ''
|
||||
|
||||
html = (
|
||||
f'<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">'
|
||||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||
f'<input type="hidden" name="updated_at" value="{esc(gp.get("updated_at") or "")}">'
|
||||
f'<div class="space-y-[12px] mt-[16px]">{sections}</div>'
|
||||
f'<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">'
|
||||
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>'
|
||||
f'{saved_html}</div></form>'
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user