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 ( + '
' + '' + '' + '' + '' + '' + "".join(rows) + '
FieldValue
' + ) + + 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 += ( + '
' + '' + '' + '' + + "".join(sub_rows) + '
#Summary
' + ) + 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'{cal_title}' + 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'
' + ) + + # Weekday header + wd_cells = "".join(f'
{esc(wd)}
' for wd in weekday_names) + wd_row = f'' + + # 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'' + ) + + entries_html = '
' + "".join(entry_btns) + '
' if entry_btns else '' + cells.append( + f'
' + f'
{day_date.day}
{entries_html}
' + ) + + 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'' + f'
' + f'
' + f'' + f'' + f'' + f'
' + f'' + 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 = '' + for nl in newsletters: + nl_slug = esc(getattr(nl, "slug", "")) + nl_name = esc(getattr(nl, "name", "")) + nl_options += f'' + + 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'' + 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 + 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'') + + 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'' + 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'
' + 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'
' + f'' + f'' + f'
{sections}
' + f'
' + f'' + f'{saved_html}
' + ) + 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)