Delete blog sx_components.py — move all rendering to callers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
Move remaining 19 rendering functions from the 2487-line sx_components.py to their direct callers: - menu_items/routes.py: menu item form, page search, nav OOB - post/admin/routes.py: calendar view, associated entries, nav OOB - sxc/pages/__init__.py: editor panel, post data inspector, preview, entries browser, settings form, edit page editor - bp/blog/routes.py: inline new post page composition Move load_service_components() call from sx_components module-level to setup_blog_pages() so .sx files still load at startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from .services.pages_data import pages_data
|
|||||||
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
from shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from shared.browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
from shared.utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
def register(url_prefix, title):
|
def register(url_prefix, title):
|
||||||
@@ -62,6 +62,19 @@ def register(url_prefix, title):
|
|||||||
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _render_new_post_page(tctx):
|
||||||
|
"""Compose a full page with blog header for new post/page creation."""
|
||||||
|
from shared.sx.helpers import root_header_sx, full_page_sx
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
|
root_hdr = await root_header_sx(tctx)
|
||||||
|
blog_hdr = await render_to_sx("menu-row-sx",
|
||||||
|
id="blog-row", level=1,
|
||||||
|
link_label_content=SxExpr("(div)"),
|
||||||
|
child_id="blog-header-child")
|
||||||
|
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||||
|
content = tctx.get("editor_html", "")
|
||||||
|
return await full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||||
|
|
||||||
SORT_MAP = {
|
SORT_MAP = {
|
||||||
"newest": "published_at DESC",
|
"newest": "published_at DESC",
|
||||||
"oldest": "published_at ASC",
|
"oldest": "published_at ASC",
|
||||||
@@ -216,19 +229,19 @@ def register(url_prefix, title):
|
|||||||
lexical_doc = json.loads(lexical_raw)
|
lexical_doc = json.loads(lexical_raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
|
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.")
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
ok, reason = validate_lexical(lexical_doc)
|
ok, reason = validate_lexical(lexical_doc)
|
||||||
if not ok:
|
if not ok:
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error=reason)
|
tctx["editor_html"] = await render_editor_panel(save_error=reason)
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
# Create directly in db_blog
|
# Create directly in db_blog
|
||||||
@@ -272,21 +285,21 @@ def register(url_prefix, title):
|
|||||||
lexical_doc = json.loads(lexical_raw)
|
lexical_doc = json.loads(lexical_raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
tctx["editor_html"] = await render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
|
||||||
tctx["is_page"] = True
|
tctx["is_page"] = True
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
ok, reason = validate_lexical(lexical_doc)
|
ok, reason = validate_lexical(lexical_doc)
|
||||||
if not ok:
|
if not ok:
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_new_post_page, render_editor_panel
|
from sxc.pages import render_editor_panel
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
|
tctx["editor_html"] = await render_editor_panel(save_error=reason, is_page=True)
|
||||||
tctx["is_page"] = True
|
tctx["is_page"] = True
|
||||||
html = await render_new_post_page(tctx)
|
html = await _render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
# Create directly in db_blog
|
# Create directly in db_blog
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from .services.menu_items import (
|
|||||||
search_pages,
|
search_pages,
|
||||||
MenuItemError,
|
MenuItemError,
|
||||||
)
|
)
|
||||||
|
from markupsafe import escape
|
||||||
from shared.sx.helpers import sx_response, render_to_sx
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
from shared.browser.app.csrf import generate_csrf_token
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
|
||||||
@@ -34,20 +36,193 @@ async def _render_menu_items_list(menu_items):
|
|||||||
menu_items=items, new_url=new_url, csrf=csrf)
|
menu_items=items, new_url=new_url, csrf=csrf)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_menu_item_form(menu_item=None) -> str:
|
||||||
|
"""Render menu item add/edit form."""
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
search_url = url_for("menu_items.search_pages_route")
|
||||||
|
is_edit = menu_item is not None
|
||||||
|
|
||||||
|
if is_edit:
|
||||||
|
action_url = url_for("menu_items.update_menu_item_route", item_id=menu_item.id)
|
||||||
|
action_attr = f'sx-put="{action_url}"'
|
||||||
|
post_id = str(menu_item.container_id) if menu_item.container_id else ""
|
||||||
|
label = getattr(menu_item, "label", "") or ""
|
||||||
|
slug = getattr(menu_item, "slug", "") or ""
|
||||||
|
fi = getattr(menu_item, "feature_image", None) or ""
|
||||||
|
else:
|
||||||
|
action_url = url_for("menu_items.create_menu_item_route")
|
||||||
|
action_attr = f'sx-post="{action_url}"'
|
||||||
|
post_id = ""
|
||||||
|
label = ""
|
||||||
|
slug = ""
|
||||||
|
fi = ""
|
||||||
|
|
||||||
|
if post_id:
|
||||||
|
img_html = (f'<img src="{fi}" alt="{label}" class="w-10 h-10 rounded-full object-cover" />'
|
||||||
|
if fi else '<div class="w-10 h-10 rounded-full bg-stone-200"></div>')
|
||||||
|
selected = (f'<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">'
|
||||||
|
f'{img_html}<div class="flex-1"><div class="font-medium">{label}</div>'
|
||||||
|
f'<div class="text-xs text-stone-500">{slug}</div></div></div>')
|
||||||
|
else:
|
||||||
|
selected = '<div id="selected-page-display" class="mb-3 hidden"></div>'
|
||||||
|
|
||||||
|
close_js = "document.getElementById('menu-item-form').innerHTML = ''"
|
||||||
|
title = "Edit Menu Item" if is_edit else "Add Menu Item"
|
||||||
|
|
||||||
|
html = f'''<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">{title}</h2>
|
||||||
|
<button type="button" onclick="{close_js}" class="text-stone-400 hover:text-stone-600">
|
||||||
|
<i class="fa fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="post_id" id="selected-post-id" value="{post_id}" />
|
||||||
|
{selected}
|
||||||
|
<form {action_attr} sx-target="#menu-items-list" sx-swap="innerHTML"
|
||||||
|
sx-include="#selected-post-id"
|
||||||
|
sx-on:afterRequest="if(event.detail.successful) {{ {close_js} }}"
|
||||||
|
class="space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{csrf}">
|
||||||
|
<div class="flex gap-2 pb-3 border-b">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
|
<i class="fa fa-save"></i> Save</button>
|
||||||
|
<button type="button" onclick="{close_js}"
|
||||||
|
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="block text-sm font-medium text-stone-700 mb-2">Select Page</label>
|
||||||
|
<input type="text" placeholder="Search for a page... (or leave blank for all)"
|
||||||
|
sx-get="{search_url}" sx-trigger="keyup changed delay:300ms, focus once"
|
||||||
|
sx-target="#page-search-results" sx-swap="innerHTML"
|
||||||
|
name="q" id="page-search-input"
|
||||||
|
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
<div id="page-search-results" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function(e) {{
|
||||||
|
var pageOption = e.target.closest('[data-page-id]');
|
||||||
|
if (pageOption) {{
|
||||||
|
var postId = pageOption.dataset.pageId;
|
||||||
|
var postTitle = pageOption.dataset.pageTitle;
|
||||||
|
var postSlug = pageOption.dataset.pageSlug;
|
||||||
|
var postImage = pageOption.dataset.pageImage;
|
||||||
|
document.getElementById('selected-post-id').value = postId;
|
||||||
|
var display = document.getElementById('selected-page-display');
|
||||||
|
display.innerHTML = '<div class="p-3 bg-stone-50 rounded flex items-center gap-3">' +
|
||||||
|
(postImage ? '<img src="' + postImage + '" alt="' + postTitle + '" class="w-10 h-10 rounded-full object-cover" />' : '<div class="w-10 h-10 rounded-full bg-stone-200"></div>') +
|
||||||
|
'<div class="flex-1"><div class="font-medium">' + postTitle + '</div><div class="text-xs text-stone-500">' + postSlug + '</div></div></div>';
|
||||||
|
display.classList.remove('hidden');
|
||||||
|
document.getElementById('page-search-results').innerHTML = '';
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
</script>'''
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_page_search_results(pages, query, page, has_more) -> str:
|
||||||
|
"""Render page search results."""
|
||||||
|
if not pages and query:
|
||||||
|
return await render_to_sx("page-search-empty", query=query)
|
||||||
|
if not pages:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for post in pages:
|
||||||
|
items.append(await render_to_sx("page-search-item",
|
||||||
|
id=post.id, title=post.title,
|
||||||
|
slug=post.slug,
|
||||||
|
feature_image=post.feature_image or None))
|
||||||
|
|
||||||
|
sentinel = ""
|
||||||
|
if has_more:
|
||||||
|
search_url = url_for("menu_items.search_pages_route")
|
||||||
|
sentinel = await render_to_sx("page-search-sentinel",
|
||||||
|
url=search_url, query=query,
|
||||||
|
next_page=page + 1)
|
||||||
|
|
||||||
|
items_sx = "(<> " + " ".join(items) + ")"
|
||||||
|
return await render_to_sx("page-search-results",
|
||||||
|
items=SxExpr(items_sx),
|
||||||
|
sentinel=SxExpr(sentinel) if sentinel else None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_menu_items_nav_oob(menu_items) -> str:
|
||||||
|
"""Render OOB nav update for menu items."""
|
||||||
|
from quart import request as qrequest
|
||||||
|
|
||||||
|
if not menu_items:
|
||||||
|
return await render_to_sx("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
|
||||||
|
|
||||||
|
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
|
||||||
|
|
||||||
|
select_colours = (
|
||||||
|
"[.hover-capable_&]:hover:bg-yellow-300"
|
||||||
|
" aria-selected:bg-stone-500 aria-selected:text-white"
|
||||||
|
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
|
||||||
|
)
|
||||||
|
nav_button_cls = (
|
||||||
|
f"justify-center cursor-pointer flex flex-row items-center gap-2"
|
||||||
|
f" rounded bg-stone-200 text-black {select_colours} p-3"
|
||||||
|
)
|
||||||
|
|
||||||
|
container_id = "menu-items-container"
|
||||||
|
arrow_cls = f"scrolling-menu-arrow-{container_id}"
|
||||||
|
|
||||||
|
scroll_hs = (
|
||||||
|
f"on load or scroll"
|
||||||
|
f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
|
||||||
|
f" remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}"
|
||||||
|
f" else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end"
|
||||||
|
)
|
||||||
|
|
||||||
|
item_parts = []
|
||||||
|
for item in menu_items:
|
||||||
|
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
|
||||||
|
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
|
||||||
|
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
|
||||||
|
|
||||||
|
href = f"/{item_slug}/"
|
||||||
|
selected = "true" if item_slug == first_seg else "false"
|
||||||
|
|
||||||
|
img_sx = await render_to_sx("img-or-placeholder", src=fi, alt=label,
|
||||||
|
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||||
|
|
||||||
|
if item_slug != "cart":
|
||||||
|
item_parts.append(await render_to_sx("blog-nav-item-link",
|
||||||
|
href=href, hx_get=f"/{item_slug}/", selected=selected,
|
||||||
|
nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
item_parts.append(await render_to_sx("blog-nav-item-plain",
|
||||||
|
href=href, selected=selected, nav_cls=nav_button_cls,
|
||||||
|
img=SxExpr(img_sx), label=label,
|
||||||
|
))
|
||||||
|
|
||||||
|
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||||
|
|
||||||
|
return await render_to_sx("scroll-nav-wrapper",
|
||||||
|
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
|
||||||
|
arrow_cls=arrow_cls,
|
||||||
|
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
|
||||||
|
scroll_hs=scroll_hs,
|
||||||
|
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
|
||||||
|
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
|
||||||
|
|
||||||
async def get_menu_items_nav_oob_async(menu_items):
|
async def get_menu_items_nav_oob_async(menu_items):
|
||||||
"""Helper to generate OOB update for root nav menu items"""
|
"""Helper to generate OOB update for root nav menu items"""
|
||||||
from sx.sx_components import render_menu_items_nav_oob
|
return await _render_menu_items_nav_oob(menu_items)
|
||||||
return await render_menu_items_nav_oob(menu_items)
|
|
||||||
|
|
||||||
@bp.get("/new/")
|
@bp.get("/new/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def new_menu_item():
|
async def new_menu_item():
|
||||||
"""Show form to create new menu item"""
|
"""Show form to create new menu item"""
|
||||||
from sx.sx_components import render_menu_item_form
|
return sx_response(_render_menu_item_form())
|
||||||
return sx_response(render_menu_item_form())
|
|
||||||
|
|
||||||
@bp.post("/")
|
@bp.post("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -85,8 +260,7 @@ def register():
|
|||||||
if not menu_item:
|
if not menu_item:
|
||||||
return await make_response("Menu item not found", 404)
|
return await make_response("Menu item not found", 404)
|
||||||
|
|
||||||
from sx.sx_components import render_menu_item_form
|
return sx_response(_render_menu_item_form(menu_item=menu_item))
|
||||||
return sx_response(render_menu_item_form(menu_item=menu_item))
|
|
||||||
|
|
||||||
@bp.put("/<int:item_id>/")
|
@bp.put("/<int:item_id>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -144,8 +318,7 @@ def register():
|
|||||||
pages, total = await search_pages(g.s, query, page, per_page)
|
pages, total = await search_pages(g.s, query, page, per_page)
|
||||||
has_more = (page * per_page) < total
|
has_more = (page * per_page) < total
|
||||||
|
|
||||||
from sx.sx_components import render_page_search_results
|
return sx_response(await _render_page_search_results(pages, query, page, has_more))
|
||||||
return sx_response(await render_page_search_results(pages, query, page, has_more))
|
|
||||||
|
|
||||||
@bp.post("/reorder/")
|
@bp.post("/reorder/")
|
||||||
@require_admin
|
@require_admin
|
||||||
|
|||||||
@@ -10,9 +10,18 @@ from quart import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from shared.browser.app.authz import require_admin, require_post_author
|
from shared.browser.app.authz import require_admin, require_post_author
|
||||||
|
from markupsafe import escape
|
||||||
from shared.sx.helpers import sx_response, render_to_sx
|
from shared.sx.helpers import sx_response, render_to_sx
|
||||||
|
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
||||||
from shared.utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_html_sx(html: str) -> str:
|
||||||
|
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
return "(raw! " + sx_serialize(html) + ")"
|
||||||
|
|
||||||
def _post_to_edit_dict(post) -> dict:
|
def _post_to_edit_dict(post) -> dict:
|
||||||
"""Convert an ORM Post to a dict matching the shape templates expect.
|
"""Convert an ORM Post to a dict matching the shape templates expect.
|
||||||
|
|
||||||
@@ -81,6 +90,232 @@ def _serialize_markets(markets, slug):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
from quart import url_for as qurl
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
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)))
|
||||||
|
|
||||||
|
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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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>'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
||||||
|
"""Render the associated entries panel."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for as qurl
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
|
has_entries = False
|
||||||
|
entry_items: list[str] = []
|
||||||
|
for calendar in all_calendars:
|
||||||
|
entries = getattr(calendar, "entries", []) or []
|
||||||
|
cal_name = getattr(calendar, "name", "")
|
||||||
|
cal_post = getattr(calendar, "post", None)
|
||||||
|
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
|
||||||
|
cal_title = getattr(cal_post, "title", "") if cal_post else ""
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
e_id = getattr(entry, "id", None)
|
||||||
|
if e_id not in associated_entry_ids:
|
||||||
|
continue
|
||||||
|
if getattr(entry, "deleted_at", None) is not None:
|
||||||
|
continue
|
||||||
|
has_entries = True
|
||||||
|
e_name = getattr(entry, "name", "")
|
||||||
|
e_start = getattr(entry, "start_at", None)
|
||||||
|
e_end = getattr(entry, "end_at", None)
|
||||||
|
|
||||||
|
toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))
|
||||||
|
|
||||||
|
img_sx = await render_to_sx("blog-entry-image", src=cal_fi, title=cal_title)
|
||||||
|
|
||||||
|
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
|
||||||
|
if e_end:
|
||||||
|
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
entry_items.append(await render_to_sx("blog-associated-entry",
|
||||||
|
confirm_text=f"This will remove {e_name} from this post",
|
||||||
|
toggle_url=toggle_url,
|
||||||
|
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||||
|
img=SxExpr(img_sx), name=e_name,
|
||||||
|
date_str=f"{cal_name} \u2022 {date_str}",
|
||||||
|
))
|
||||||
|
|
||||||
|
if has_entries:
|
||||||
|
content_sx = await render_to_sx("blog-associated-entries-content",
|
||||||
|
items=SxExpr("(<> " + " ".join(entry_items) + ")"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content_sx = await render_to_sx("blog-associated-entries-empty")
|
||||||
|
|
||||||
|
return await render_to_sx("blog-associated-entries-panel", content=SxExpr(content_sx))
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
||||||
|
"""Render the OOB nav entries swap."""
|
||||||
|
entries_list = []
|
||||||
|
if associated_entries and hasattr(associated_entries, "entries"):
|
||||||
|
entries_list = associated_entries.entries or []
|
||||||
|
|
||||||
|
has_items = bool(entries_list or calendars)
|
||||||
|
|
||||||
|
if not has_items:
|
||||||
|
return await render_to_sx("blog-nav-entries-empty")
|
||||||
|
|
||||||
|
select_colours = (
|
||||||
|
"[.hover-capable_&]:hover:bg-yellow-300"
|
||||||
|
" aria-selected:bg-stone-500 aria-selected:text-white"
|
||||||
|
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
|
||||||
|
)
|
||||||
|
nav_cls = (
|
||||||
|
f"justify-center cursor-pointer flex flex-row items-center gap-2"
|
||||||
|
f" rounded bg-stone-200 text-black {select_colours} p-2"
|
||||||
|
)
|
||||||
|
|
||||||
|
post_slug = post.get("slug", "")
|
||||||
|
|
||||||
|
scroll_hs = (
|
||||||
|
"on load or scroll"
|
||||||
|
" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
|
||||||
|
" remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow"
|
||||||
|
" else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"
|
||||||
|
)
|
||||||
|
|
||||||
|
item_parts = []
|
||||||
|
|
||||||
|
for entry in entries_list:
|
||||||
|
e_name = getattr(entry, "name", "")
|
||||||
|
e_start = getattr(entry, "start_at", None)
|
||||||
|
e_end = getattr(entry, "end_at", None)
|
||||||
|
cal_slug = getattr(entry, "calendar_slug", "")
|
||||||
|
|
||||||
|
if e_start:
|
||||||
|
entry_path = (
|
||||||
|
f"/{post_slug}/{cal_slug}/"
|
||||||
|
f"{e_start.year}/{e_start.month}/{e_start.day}"
|
||||||
|
f"/entries/{getattr(entry, 'id', '')}/"
|
||||||
|
)
|
||||||
|
date_str = e_start.strftime("%b %d, %Y at %H:%M")
|
||||||
|
if e_end:
|
||||||
|
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||||
|
else:
|
||||||
|
entry_path = f"/{post_slug}/{cal_slug}/"
|
||||||
|
date_str = ""
|
||||||
|
|
||||||
|
item_parts.append(await render_to_sx("calendar-entry-nav",
|
||||||
|
href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
|
||||||
|
))
|
||||||
|
|
||||||
|
for calendar in (calendars or []):
|
||||||
|
cal_name = getattr(calendar, "name", "")
|
||||||
|
cal_slug = getattr(calendar, "slug", "")
|
||||||
|
cal_path = f"/{post_slug}/{cal_slug}/"
|
||||||
|
|
||||||
|
item_parts.append(await render_to_sx("blog-nav-calendar-item",
|
||||||
|
href=cal_path, nav_cls=nav_cls, name=cal_name,
|
||||||
|
))
|
||||||
|
|
||||||
|
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||||
|
|
||||||
|
return await render_to_sx("scroll-nav-wrapper",
|
||||||
|
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
|
||||||
|
arrow_cls="entries-nav-arrow",
|
||||||
|
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
|
||||||
|
scroll_hs=scroll_hs,
|
||||||
|
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
|
||||||
|
items=SxExpr(items_sx) if items_sx else None, oob=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||||
|
|
||||||
@@ -220,8 +455,7 @@ def register():
|
|||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||||
|
|
||||||
from sx.sx_components import render_calendar_view
|
html = _render_calendar_view(
|
||||||
html = render_calendar_view(
|
|
||||||
calendar_obj, year, month, month_name, weekday_names, weeks,
|
calendar_obj, year, month, month_name, weekday_names, weeks,
|
||||||
prev_month, prev_month_year, next_month, next_month_year,
|
prev_month, prev_month_year, next_month, next_month_year,
|
||||||
prev_year, next_year, month_entries, associated_entry_ids,
|
prev_year, next_year, month_entries, associated_entry_ids,
|
||||||
@@ -273,11 +507,9 @@ def register():
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
# Return the associated entries admin list + OOB update for nav entries
|
# Return the associated entries admin list + OOB update for nav entries
|
||||||
from sx.sx_components import render_associated_entries, render_nav_entries_oob
|
|
||||||
|
|
||||||
post = g.post_data["post"]
|
post = g.post_data["post"]
|
||||||
admin_list = await render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
admin_list = await _render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
|
||||||
nav_entries_html = await render_nav_entries_oob(associated_entries, calendars, post)
|
nav_entries_html = await _render_nav_entries_oob(associated_entries, calendars, post)
|
||||||
|
|
||||||
return sx_response(admin_list + nav_entries_html)
|
return sx_response(admin_list + nav_entries_html)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,11 @@ def setup_blog_pages() -> None:
|
|||||||
def _load_blog_page_files() -> None:
|
def _load_blog_page_files() -> None:
|
||||||
import os
|
import os
|
||||||
from shared.sx.pages import load_page_dir
|
from shared.sx.pages import load_page_dir
|
||||||
|
from shared.sx.jinja_bridge import load_service_components
|
||||||
|
# Load blog .sx component definitions + handler definitions
|
||||||
|
# __file__ = blog/sxc/pages/__init__.py → blog root is 3 levels up
|
||||||
|
blog_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
load_service_components(blog_dir, service_name="blog")
|
||||||
load_page_dir(os.path.dirname(__file__), "blog")
|
load_page_dir(os.path.dirname(__file__), "blog")
|
||||||
|
|
||||||
|
|
||||||
@@ -325,6 +330,191 @@ async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
|
|||||||
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rendering helpers (moved from sx_components)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _raw_html_sx(html: str) -> str:
|
||||||
|
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
|
||||||
|
from shared.sx.parser import serialize as sx_serialize
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
return "(raw! " + sx_serialize(html) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
async def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
|
||||||
|
"""Build the WYSIWYG editor panel HTML for new post/page creation."""
|
||||||
|
import os
|
||||||
|
from quart import url_for as qurl, current_app
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from shared.sx.helpers import render_to_sx
|
||||||
|
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||||
|
create_label = "Create Page" if is_page else "Create Post"
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
if save_error:
|
||||||
|
parts.append(await render_to_sx("blog-editor-error", error=str(save_error)))
|
||||||
|
|
||||||
|
parts.append(await render_to_sx("blog-editor-form",
|
||||||
|
csrf=csrf, title_placeholder=title_placeholder,
|
||||||
|
create_label=create_label,
|
||||||
|
))
|
||||||
|
|
||||||
|
parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css))
|
||||||
|
parts.append(await render_to_sx("sx-editor-styles"))
|
||||||
|
|
||||||
|
init_js = (
|
||||||
|
"console.log('[EDITOR-DEBUG] init script running');\n"
|
||||||
|
"(function() {\n"
|
||||||
|
" console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n"
|
||||||
|
" function init() {\n"
|
||||||
|
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
|
||||||
|
f" var uploadUrl = '{upload_image_url}';\n"
|
||||||
|
" var uploadUrls = {\n"
|
||||||
|
" image: uploadUrl,\n"
|
||||||
|
f" media: '{upload_media_url}',\n"
|
||||||
|
f" file: '{upload_file_url}',\n"
|
||||||
|
" };\n"
|
||||||
|
"\n"
|
||||||
|
" var fileInput = document.getElementById('feature-image-file');\n"
|
||||||
|
" var addBtn = document.getElementById('feature-image-add-btn');\n"
|
||||||
|
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
|
||||||
|
" var preview = document.getElementById('feature-image-preview');\n"
|
||||||
|
" var emptyState = document.getElementById('feature-image-empty');\n"
|
||||||
|
" var filledState = document.getElementById('feature-image-filled');\n"
|
||||||
|
" var hiddenUrl = document.getElementById('feature-image-input');\n"
|
||||||
|
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
|
||||||
|
" var captionInput = document.getElementById('feature-image-caption');\n"
|
||||||
|
" var uploading = document.getElementById('feature-image-uploading');\n"
|
||||||
|
"\n"
|
||||||
|
" function showFilled(url) {\n"
|
||||||
|
" preview.src = url;\n"
|
||||||
|
" hiddenUrl.value = url;\n"
|
||||||
|
" emptyState.classList.add('hidden');\n"
|
||||||
|
" filledState.classList.remove('hidden');\n"
|
||||||
|
" uploading.classList.add('hidden');\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" function showEmpty() {\n"
|
||||||
|
" preview.src = '';\n"
|
||||||
|
" hiddenUrl.value = '';\n"
|
||||||
|
" hiddenCaption.value = '';\n"
|
||||||
|
" captionInput.value = '';\n"
|
||||||
|
" emptyState.classList.remove('hidden');\n"
|
||||||
|
" filledState.classList.add('hidden');\n"
|
||||||
|
" uploading.classList.add('hidden');\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" function uploadFile(file) {\n"
|
||||||
|
" emptyState.classList.add('hidden');\n"
|
||||||
|
" uploading.classList.remove('hidden');\n"
|
||||||
|
" var fd = new FormData();\n"
|
||||||
|
" fd.append('file', file);\n"
|
||||||
|
" fetch(uploadUrl, {\n"
|
||||||
|
" method: 'POST',\n"
|
||||||
|
" body: fd,\n"
|
||||||
|
" headers: { 'X-CSRFToken': csrfToken },\n"
|
||||||
|
" })\n"
|
||||||
|
" .then(function(r) {\n"
|
||||||
|
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
|
||||||
|
" return r.json();\n"
|
||||||
|
" })\n"
|
||||||
|
" .then(function(data) {\n"
|
||||||
|
" var url = data.images && data.images[0] && data.images[0].url;\n"
|
||||||
|
" if (url) showFilled(url);\n"
|
||||||
|
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
|
||||||
|
" })\n"
|
||||||
|
" .catch(function(e) {\n"
|
||||||
|
" showEmpty();\n"
|
||||||
|
" alert(e.message);\n"
|
||||||
|
" });\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
|
||||||
|
" preview.addEventListener('click', function() { fileInput.click(); });\n"
|
||||||
|
" deleteBtn.addEventListener('click', function(e) {\n"
|
||||||
|
" e.stopPropagation();\n"
|
||||||
|
" showEmpty();\n"
|
||||||
|
" });\n"
|
||||||
|
" fileInput.addEventListener('change', function() {\n"
|
||||||
|
" if (fileInput.files && fileInput.files[0]) {\n"
|
||||||
|
" uploadFile(fileInput.files[0]);\n"
|
||||||
|
" fileInput.value = '';\n"
|
||||||
|
" }\n"
|
||||||
|
" });\n"
|
||||||
|
" captionInput.addEventListener('input', function() {\n"
|
||||||
|
" hiddenCaption.value = captionInput.value;\n"
|
||||||
|
" });\n"
|
||||||
|
"\n"
|
||||||
|
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
|
||||||
|
" function autoResize() {\n"
|
||||||
|
" excerpt.style.height = 'auto';\n"
|
||||||
|
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
|
||||||
|
" }\n"
|
||||||
|
" excerpt.addEventListener('input', autoResize);\n"
|
||||||
|
" autoResize();\n"
|
||||||
|
"\n"
|
||||||
|
" window.mountEditor('lexical-editor', {\n"
|
||||||
|
" initialJson: null,\n"
|
||||||
|
" csrfToken: csrfToken,\n"
|
||||||
|
" uploadUrls: uploadUrls,\n"
|
||||||
|
f" oembedUrl: '{oembed_url}',\n"
|
||||||
|
f" unsplashApiKey: '{unsplash_key}',\n"
|
||||||
|
f" snippetsUrl: '{snippets_url}',\n"
|
||||||
|
" });\n"
|
||||||
|
"\n"
|
||||||
|
" if (typeof SxEditor !== 'undefined') {\n"
|
||||||
|
" SxEditor.mount('sx-editor', {\n"
|
||||||
|
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
|
||||||
|
" csrfToken: csrfToken,\n"
|
||||||
|
" uploadUrls: uploadUrls,\n"
|
||||||
|
f" oembedUrl: '{oembed_url}',\n"
|
||||||
|
" onChange: function(sx) {\n"
|
||||||
|
" document.getElementById('sx-content-input').value = sx;\n"
|
||||||
|
" }\n"
|
||||||
|
" });\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" document.addEventListener('keydown', function(e) {\n"
|
||||||
|
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
|
||||||
|
" e.preventDefault();\n"
|
||||||
|
" document.getElementById('post-new-form').requestSubmit();\n"
|
||||||
|
" }\n"
|
||||||
|
" });\n"
|
||||||
|
" }\n"
|
||||||
|
"\n"
|
||||||
|
" if (typeof window.mountEditor === 'function') {\n"
|
||||||
|
" init();\n"
|
||||||
|
" } else {\n"
|
||||||
|
" var _t = setInterval(function() {\n"
|
||||||
|
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
|
||||||
|
" }, 50);\n"
|
||||||
|
" }\n"
|
||||||
|
"})();\n"
|
||||||
|
)
|
||||||
|
parts.append(await render_to_sx("blog-editor-scripts",
|
||||||
|
js_src=editor_js,
|
||||||
|
sx_editor_js_src=sx_editor_js,
|
||||||
|
init_js=init_js))
|
||||||
|
|
||||||
|
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Page helpers (async functions available in .sx defpage expressions)
|
# Page helpers (async functions available in .sx defpage expressions)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -346,12 +536,10 @@ def _register_blog_helpers() -> None:
|
|||||||
# --- Editor helpers ---
|
# --- Editor helpers ---
|
||||||
|
|
||||||
async def _h_editor_content(**kw):
|
async def _h_editor_content(**kw):
|
||||||
from sx.sx_components import render_editor_panel
|
|
||||||
return await render_editor_panel()
|
return await render_editor_panel()
|
||||||
|
|
||||||
|
|
||||||
async def _h_editor_page_content(**kw):
|
async def _h_editor_page_content(**kw):
|
||||||
from sx.sx_components import render_editor_panel
|
|
||||||
return await render_editor_panel(is_page=True)
|
return await render_editor_panel(is_page=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -364,31 +552,164 @@ async def _h_post_admin_content(slug=None, **kw):
|
|||||||
|
|
||||||
async def _h_post_data_content(slug=None, **kw):
|
async def _h_post_data_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from shared.sx.page import get_template_context
|
from quart import g
|
||||||
from sx.sx_components import _post_data_content_sx
|
from markupsafe import escape as esc
|
||||||
tctx = await get_template_context()
|
|
||||||
return _post_data_content_sx(tctx)
|
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 _h_post_preview_content(slug=None, **kw):
|
async def _h_post_preview_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from quart import g
|
from quart import g
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.helpers import render_to_sx
|
||||||
from sx.sx_components import _preview_main_panel_sx
|
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
||||||
preview_data = await services.get("blog_page").preview_data(g.s)
|
|
||||||
tctx = await get_template_context()
|
preview = await services.get("blog_page").preview_data(g.s)
|
||||||
tctx.update(preview_data)
|
|
||||||
return await _preview_main_panel_sx(tctx)
|
sections: list[str] = []
|
||||||
|
if preview.get("sx_pretty"):
|
||||||
|
sections.append(await render_to_sx("blog-preview-section",
|
||||||
|
title="S-Expression Source", content=SxExpr(preview["sx_pretty"])))
|
||||||
|
if preview.get("json_pretty"):
|
||||||
|
sections.append(await render_to_sx("blog-preview-section",
|
||||||
|
title="Lexical JSON", content=SxExpr(preview["json_pretty"])))
|
||||||
|
if preview.get("sx_rendered"):
|
||||||
|
rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["sx_rendered"])}))'
|
||||||
|
sections.append(await render_to_sx("blog-preview-section",
|
||||||
|
title="SX Rendered", content=SxExpr(rendered_sx)))
|
||||||
|
if preview.get("lex_rendered"):
|
||||||
|
rendered_sx = f'(div :class "blog-content prose max-w-none" (raw! {sx_serialize(preview["lex_rendered"])}))'
|
||||||
|
sections.append(await render_to_sx("blog-preview-section",
|
||||||
|
title="Lexical Rendered", content=SxExpr(rendered_sx)))
|
||||||
|
|
||||||
|
if not sections:
|
||||||
|
return '(div :class "p-8 text-stone-500" "No content to preview.")'
|
||||||
|
|
||||||
|
inner = " ".join(sections)
|
||||||
|
return await render_to_sx("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))
|
||||||
|
|
||||||
|
|
||||||
async def _h_post_entries_content(slug=None, **kw):
|
async def _h_post_entries_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from quart import g
|
from quart import g, url_for as qurl
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from markupsafe import escape as esc
|
||||||
from shared.models.calendars import Calendar
|
from shared.models.calendars import Calendar
|
||||||
|
from shared.utils import host_url
|
||||||
from bp.post.services.entry_associations import get_post_entry_ids
|
from bp.post.services.entry_associations import get_post_entry_ids
|
||||||
|
from bp.post.admin.routes import _render_associated_entries
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
|
post_slug = g.post_data["post"]["slug"]
|
||||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||||
result = await g.s.execute(
|
result = await g.s.execute(
|
||||||
select(Calendar)
|
select(Calendar)
|
||||||
@@ -398,21 +719,62 @@ async def _h_post_entries_content(slug=None, **kw):
|
|||||||
all_calendars = result.scalars().all()
|
all_calendars = result.scalars().all()
|
||||||
for calendar in all_calendars:
|
for calendar in all_calendars:
|
||||||
await g.s.refresh(calendar, ["entries", "post"])
|
await g.s.refresh(calendar, ["entries", "post"])
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import _post_entries_content_sx
|
# Associated entries list
|
||||||
tctx = await get_template_context()
|
assoc_html = await _render_associated_entries(all_calendars, associated_entry_ids, post_slug)
|
||||||
tctx["all_calendars"] = all_calendars
|
|
||||||
tctx["associated_entry_ids"] = associated_entry_ids
|
# Calendar browser
|
||||||
return await _post_entries_content_sx(tctx)
|
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 = esc(getattr(cal_post, "title", "")) if cal_post else ""
|
||||||
|
cal_name = esc(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="{esc(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="{esc(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>'
|
||||||
|
|
||||||
|
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 _h_post_settings_content(slug=None, **kw):
|
async def _h_post_settings_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
|
from markupsafe import escape as esc
|
||||||
from models.ghost_content import Post
|
from models.ghost_content import Post
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
from bp.post.admin.routes import _post_to_edit_dict
|
from bp.post.admin.routes import _post_to_edit_dict
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
post = (await g.s.execute(
|
post = (await g.s.execute(
|
||||||
sa_select(Post)
|
sa_select(Post)
|
||||||
@@ -421,41 +783,339 @@ async def _h_post_settings_content(slug=None, **kw):
|
|||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||||
save_success = request.args.get("saved") == "1"
|
save_success = request.args.get("saved") == "1"
|
||||||
from shared.sx.page import get_template_context
|
csrf = generate_csrf_token()
|
||||||
from sx.sx_components import _post_settings_content_sx
|
|
||||||
tctx = await get_template_context()
|
p = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
|
||||||
tctx["ghost_post"] = ghost_post
|
is_page = p.get("is_page", False)
|
||||||
tctx["save_success"] = save_success
|
gp = ghost_post
|
||||||
return _post_settings_content_sx(tctx)
|
|
||||||
|
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>')
|
||||||
|
|
||||||
|
# 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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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_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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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 _h_post_edit_content(slug=None, **kw):
|
async def _h_post_edit_content(slug=None, **kw):
|
||||||
await _ensure_post_data(slug)
|
await _ensure_post_data(slug)
|
||||||
from quart import g, request
|
import os
|
||||||
|
from quart import g, request as qrequest, url_for as qurl, current_app
|
||||||
from models.ghost_content import Post
|
from models.ghost_content import Post
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from shared.infrastructure.data_client import fetch_data
|
from shared.infrastructure.data_client import fetch_data
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from shared.sx.helpers import render_to_sx
|
||||||
|
from shared.sx.parser import SxExpr, serialize as sx_serialize
|
||||||
from bp.post.admin.routes import _post_to_edit_dict
|
from bp.post.admin.routes import _post_to_edit_dict
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
post = (await g.s.execute(
|
db_post = (await g.s.execute(
|
||||||
sa_select(Post)
|
sa_select(Post)
|
||||||
.where(Post.id == post_id)
|
.where(Post.id == post_id)
|
||||||
.options(selectinload(Post.tags))
|
.options(selectinload(Post.tags))
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
ghost_post = _post_to_edit_dict(post) if post else {}
|
ghost_post = _post_to_edit_dict(db_post) if db_post else {}
|
||||||
save_success = request.args.get("saved") == "1"
|
save_success = qrequest.args.get("saved") == "1"
|
||||||
save_error = request.args.get("error", "")
|
save_error = qrequest.args.get("error", "")
|
||||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import _post_edit_content_sx
|
csrf = generate_csrf_token()
|
||||||
tctx = await get_template_context()
|
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
|
||||||
tctx["ghost_post"] = ghost_post
|
editor_css = asset_url_fn("scripts/editor.css")
|
||||||
tctx["save_success"] = save_success
|
editor_js = asset_url_fn("scripts/editor.js")
|
||||||
tctx["save_error"] = save_error
|
sx_editor_js = asset_url_fn("scripts/sx-editor.js")
|
||||||
tctx["newsletters"] = newsletters
|
|
||||||
return await _post_edit_content_sx(tctx)
|
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 = ghost_post.get("title") or ""
|
||||||
|
excerpt_val = ghost_post.get("custom_excerpt") or ""
|
||||||
|
updated_at = ghost_post.get("updated_at") or ""
|
||||||
|
status = ghost_post.get("status") or "draft"
|
||||||
|
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
|
||||||
|
sx_content = ghost_post.get("sx_content") or ""
|
||||||
|
has_sx = bool(sx_content)
|
||||||
|
|
||||||
|
already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
|
||||||
|
email_obj = ghost_post.get("email")
|
||||||
|
if email_obj and not isinstance(email_obj, dict):
|
||||||
|
already_emailed = bool(getattr(email_obj, "status", None))
|
||||||
|
|
||||||
|
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||||
|
|
||||||
|
# Newsletter options as SX fragment
|
||||||
|
nl_parts = ['(option :value "" "Select newsletter\u2026")']
|
||||||
|
for nl in newsletters:
|
||||||
|
nl_slug = sx_serialize(getattr(nl, "slug", ""))
|
||||||
|
nl_name = sx_serialize(getattr(nl, "name", ""))
|
||||||
|
nl_parts.append(f"(option :value {nl_slug} {nl_name})")
|
||||||
|
nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")")
|
||||||
|
|
||||||
|
# Footer extra badges as SX fragment
|
||||||
|
badge_parts: list[str] = []
|
||||||
|
if save_success:
|
||||||
|
badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")')
|
||||||
|
publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
|
||||||
|
if publish_requested:
|
||||||
|
badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")')
|
||||||
|
if post.get("publish_requested"):
|
||||||
|
badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")')
|
||||||
|
if already_emailed:
|
||||||
|
nl_name = ""
|
||||||
|
newsletter = ghost_post.get("newsletter")
|
||||||
|
if newsletter:
|
||||||
|
nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "")
|
||||||
|
suffix = f" to {nl_name}" if nl_name else ""
|
||||||
|
badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")')
|
||||||
|
footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
if save_error:
|
||||||
|
parts.append(await render_to_sx("blog-editor-error", error=save_error))
|
||||||
|
|
||||||
|
parts.append(await render_to_sx("blog-editor-edit-form",
|
||||||
|
csrf=csrf,
|
||||||
|
updated_at=str(updated_at),
|
||||||
|
title_val=title_val,
|
||||||
|
excerpt_val=excerpt_val,
|
||||||
|
feature_image=feature_image,
|
||||||
|
feature_image_caption=feature_image_caption,
|
||||||
|
sx_content_val=sx_content,
|
||||||
|
lexical_json=lexical_json,
|
||||||
|
has_sx=has_sx,
|
||||||
|
title_placeholder=title_placeholder,
|
||||||
|
status=status,
|
||||||
|
already_emailed=already_emailed,
|
||||||
|
newsletter_options=nl_opts_sx,
|
||||||
|
footer_extra=footer_extra_sx,
|
||||||
|
))
|
||||||
|
|
||||||
|
parts.append(await render_to_sx("blog-editor-publish-js", already_emailed=already_emailed))
|
||||||
|
parts.append(await render_to_sx("blog-editor-styles", css_href=editor_css))
|
||||||
|
parts.append(await render_to_sx("sx-editor-styles"))
|
||||||
|
|
||||||
|
init_js = (
|
||||||
|
'(function() {'
|
||||||
|
" 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}',"
|
||||||
|
' });'
|
||||||
|
" if (typeof SxEditor !== 'undefined') {"
|
||||||
|
" SxEditor.mount('sx-editor', {"
|
||||||
|
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
|
||||||
|
' csrfToken: csrfToken,'
|
||||||
|
' uploadUrls: uploadUrls,'
|
||||||
|
f" oembedUrl: '{oembed_url}',"
|
||||||
|
' onChange: function(sx) {'
|
||||||
|
" document.getElementById('sx-content-input').value = sx;"
|
||||||
|
' }'
|
||||||
|
' });'
|
||||||
|
' }'
|
||||||
|
" 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); }'
|
||||||
|
'})();'
|
||||||
|
)
|
||||||
|
parts.append(await render_to_sx("blog-editor-scripts",
|
||||||
|
js_src=editor_js,
|
||||||
|
sx_editor_js_src=sx_editor_js,
|
||||||
|
init_js=init_js))
|
||||||
|
|
||||||
|
return "(<> " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user