Migrate all apps to defpage declarative page routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m41s

Replace Python GET page handlers with declarative defpage definitions in .sx
files across all 8 apps (sx docs, orders, account, market, cart, federation,
events, blog). Each app now has sxc/pages/ with setup functions, layout
registrations, page helpers, and .sx defpage declarations.

Core infrastructure: add g I/O primitive, PageDef support for auth/layout/
data/content/filter/aside/menu slots, post_author auth level, and custom
layout registration. Remove ~1400 lines of render_*_page/render_*_oob
boilerplate. Update all endpoint references in routes, sx_components, and
templates to defpage_* naming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 14:52:34 +00:00
parent 5b4cacaf19
commit c243d17eeb
108 changed files with 3598 additions and 2851 deletions

View File

@@ -20,6 +20,7 @@ from bp import (
register_data,
register_actions,
)
from sxc.pages import setup_blog_pages
async def blog_context() -> dict:
@@ -80,6 +81,8 @@ async def blog_context() -> dict:
def create_app() -> "Quart":
from services import register_domain_services
setup_blog_pages()
app = create_base_app(
"blog",
context_fn=blog_context,

View File

@@ -27,33 +27,22 @@ def register(url_prefix):
"base_title": f"{config()['title']} settings",
}
@bp.get("/")
@require_admin
async def home():
from shared.sx.page import get_template_context
from sx.sx_components import render_settings_page, render_settings_oob
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_settings_home" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _settings_main_panel_sx
tctx = await get_template_context()
g.settings_content = _settings_main_panel_sx(tctx)
elif "defpage_cache_page" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _cache_main_panel_sx
tctx = await get_template_context()
g.cache_content = _cache_main_panel_sx(tctx)
tctx = await get_template_context()
if not is_htmx_request():
html = await render_settings_page(tctx)
return await make_response(html)
else:
sx_src = await render_settings_oob(tctx)
return sx_response(sx_src)
@bp.get("/cache/")
@require_admin
async def cache():
from shared.sx.page import get_template_context
from sx.sx_components import render_cache_page, render_cache_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_cache_page(tctx)
return await make_response(html)
else:
sx_src = await render_cache_oob(tctx)
return sx_response(sx_src)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
@bp.post("/cache_clear/")
@require_admin
@@ -65,7 +54,7 @@ def register(url_prefix):
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
return sx_response(html)
return redirect(url_for("settings.cache"))
return redirect(url_for("settings.defpage_cache_page"))
return bp

View File

@@ -46,27 +46,52 @@ async def _unassigned_tags(session):
def register():
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
@bp.get("/")
@require_admin
async def index():
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_tag_groups_page" in ep:
groups = list(
(await g.s.execute(
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
)).scalars()
)
unassigned = await _unassigned_tags(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_main_panel_sx
tctx = await get_template_context()
tctx.update({"groups": groups, "unassigned_tags": unassigned})
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
elif "defpage_tag_group_edit" in ep:
tag_id = (request.view_args or {}).get("id")
tg = await g.s.get(TagGroup, tag_id)
if not tg:
from quart import abort
abort(404)
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
)).scalars()
)
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
from shared.sx.page import get_template_context
from sx.sx_components import _tag_groups_edit_main_panel_sx
tctx = await get_template_context()
tctx.update({
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": set(assigned_rows),
})
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
ctx = {"groups": groups, "unassigned_tags": unassigned}
from shared.sx.page import get_template_context
from sx.sx_components import render_tag_groups_page, render_tag_groups_oob
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
return await make_response(await render_tag_groups_page(tctx))
else:
return sx_response(await render_tag_groups_oob(tctx))
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
@bp.post("/")
@require_admin
@@ -74,7 +99,7 @@ def register():
form = await request.form
name = (form.get("name") or "").strip()
if not name:
return redirect(url_for("blog.tag_groups_admin.index"))
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None
@@ -90,55 +115,14 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.index"))
@bp.get("/<int:id>/")
@require_admin
async def edit(id: int):
tg = await g.s.get(TagGroup, id)
if not tg:
return redirect(url_for("blog.tag_groups_admin.index"))
# Assigned tag IDs for this group
assigned_rows = list(
(await g.s.execute(
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
)).scalars()
)
assigned_tag_ids = set(assigned_rows)
# All public, non-deleted tags
all_tags = list(
(await g.s.execute(
select(Tag).where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
).order_by(Tag.name)
)).scalars()
)
ctx = {
"group": tg,
"all_tags": all_tags,
"assigned_tag_ids": assigned_tag_ids,
}
from shared.sx.page import get_template_context
from sx.sx_components import render_tag_group_edit_page, render_tag_group_edit_oob
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
return await make_response(await render_tag_group_edit_page(tctx))
else:
return sx_response(await render_tag_group_edit_oob(tctx))
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
@bp.post("/<int:id>/")
@require_admin
async def save(id: int):
tg = await g.s.get(TagGroup, id)
if not tg:
return redirect(url_for("blog.tag_groups_admin.index"))
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
form = await request.form
name = (form.get("name") or "").strip()
@@ -169,7 +153,7 @@ def register():
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id))
@bp.post("/<int:id>/delete/")
@require_admin
@@ -179,6 +163,6 @@ def register():
await g.s.delete(tg)
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.index"))
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
return bp

View File

@@ -51,10 +51,19 @@ def register(url_prefix, title):
pass
@blogs_bp.before_request
def route():
async def route():
g.makeqs_factory = makeqs_factory
ep = request.endpoint or ""
if "defpage_new_post" in ep:
from sx.sx_components import render_editor_panel
g.editor_content = render_editor_panel()
elif "defpage_new_page" in ep:
from sx.sx_components import render_editor_panel
g.editor_page_content = render_editor_panel(is_page=True)
from shared.sx.pages import mount_pages
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
@blogs_bp.context_processor
async def inject_root():
return {
@@ -215,21 +224,6 @@ def register(url_prefix, title):
sx_src = await render_blog_oob(tctx)
return sx_response(sx_src)
@blogs_bp.get("/new/")
@require_admin
async def new_post():
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel()
if not is_htmx_request():
html = await render_new_post_page(tctx)
return await make_response(html)
else:
sx_src = await render_new_post_oob(tctx)
return sx_response(sx_src)
@blogs_bp.post("/new/")
@require_admin
async def new_post_save():
@@ -283,25 +277,9 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog")
# Redirect to the edit page
return redirect(host_url(url_for("blog.post.admin.edit", slug=post.slug)))
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)))
@blogs_bp.get("/new-page/")
@require_admin
async def new_page():
from shared.sx.page import get_template_context
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(is_page=True)
tctx["is_page"] = True
if not is_htmx_request():
html = await render_new_post_page(tctx)
return await make_response(html)
else:
sx_src = await render_new_post_oob(tctx)
return sx_response(sx_src)
@blogs_bp.post("/new-page/")
@require_admin
async def new_page_save():
@@ -357,7 +335,7 @@ def register(url_prefix, title):
await invalidate_tag_cache("blog")
# Redirect to the page admin
return redirect(host_url(url_for("blog.post.admin.edit", slug=page.slug)))
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug)))
@blogs_bp.get("/drafts/")

View File

@@ -23,24 +23,19 @@ def register():
from sx.sx_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items)
@bp.get("/")
@require_admin
async def list_menu_items():
"""List all menu items"""
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
menu_items = await get_all_menu_items(g.s)
from shared.sx.page import get_template_context
from sx.sx_components import render_menu_items_page, render_menu_items_oob
from sx.sx_components import _menu_items_main_panel_sx
tctx = await get_template_context()
tctx["menu_items"] = menu_items
if not is_htmx_request():
html = await render_menu_items_page(tctx)
return await make_response(html)
else:
sx_src = await render_menu_items_oob(tctx)
return sx_response(sx_src)
g.menu_items_content = _menu_items_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["menu-items-page"])
@bp.get("/new/")
@require_admin

View File

@@ -55,51 +55,154 @@ def _post_to_edit_dict(post) -> dict:
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.get("/")
@require_admin
async def admin(slug: str):
from shared.browser.app.utils.htmx import is_htmx_request
from sqlalchemy import select
from shared.models.page_config import PageConfig
@bp.before_request
async def _prepare_page_data():
ep = request.endpoint or ""
if "defpage_post_admin" in ep:
from sqlalchemy import select
from shared.models.page_config import PageConfig
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
from shared.sx.page import get_template_context
from sx.sx_components import _post_admin_main_panel_sx
tctx = await get_template_context()
tctx.update({
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
})
g.post_admin_content = _post_admin_main_panel_sx(tctx)
# Load features for page admin (page_configs now lives in db_blog)
post = (g.post_data or {}).get("post", {})
features = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post["id"],
)
)).scalar_one_or_none()
if pc:
features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
elif "defpage_post_data" in ep:
from shared.sx.page import get_template_context
from sx.sx_components import _post_data_content_sx
tctx = await get_template_context()
g.post_data_content = _post_data_content_sx(tctx)
ctx = {
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
}
elif "defpage_post_preview" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
preview_ctx = {}
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
from shared.sx.page import get_template_context
from sx.sx_components import _preview_main_panel_sx
tctx = await get_template_context()
tctx.update(preview_ctx)
g.post_preview_content = _preview_main_panel_sx(tctx)
from shared.sx.page import get_template_context
from sx.sx_components import render_post_admin_page, render_post_admin_oob
elif "defpage_post_entries" in ep:
from sqlalchemy import select
from shared.models.calendars import Calendar
from ..services.entry_associations import get_post_entry_ids
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import _post_entries_content_sx
tctx = await get_template_context()
tctx["all_calendars"] = all_calendars
tctx["associated_entry_ids"] = associated_entry_ids
g.post_entries_content = _post_entries_content_sx(tctx)
tctx = await get_template_context()
tctx.update(ctx)
if not is_htmx_request():
html = await render_post_admin_page(tctx)
return await make_response(html)
else:
sx_src = await render_post_admin_oob(tctx)
return sx_response(sx_src)
elif "defpage_post_settings" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import _post_settings_content_sx
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
g.post_settings_content = _post_settings_content_sx(tctx)
elif "defpage_post_edit" in ep:
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
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
tctx = await get_template_context()
tctx["ghost_post"] = ghost_post
tctx["save_success"] = save_success
tctx["save_error"] = save_error
tctx["newsletters"] = newsletters
g.post_edit_content = _post_edit_content_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=[
"post-admin", "post-data", "post-preview",
"post-entries", "post-settings", "post-edit",
])
@bp.put("/features/")
@require_admin
@@ -184,77 +287,6 @@ def register():
)
return sx_response(html)
@bp.get("/data/")
@require_admin
async def data(slug: str):
from shared.sx.page import get_template_context
from sx.sx_components import render_post_data_page, render_post_data_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_post_data_page(tctx)
return await make_response(html)
else:
sx_src = await render_post_data_oob(tctx)
return sx_response(sx_src)
@bp.get("/preview/")
@require_admin
async def preview(slug: str):
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from shared.sx.page import get_template_context
from sx.sx_components import render_post_preview_page, render_post_preview_oob
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post).where(Post.id == post_id)
)).scalar_one_or_none()
# Build the 4 preview views
preview_ctx = {}
# 1. Prettified sx source
sx_content = getattr(post, "sx_content", None) or ""
if sx_content:
from shared.sx.prettify import sx_to_pretty_sx
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
# 2. Prettified lexical JSON
lexical_raw = getattr(post, "lexical", None) or ""
if lexical_raw:
from shared.sx.prettify import json_to_pretty_sx
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
# 3. SX rendered preview
if sx_content:
from shared.sx.parser import parse as sx_parse
from shared.sx.html import render as sx_html_render
from shared.sx.jinja_bridge import _COMPONENT_ENV
try:
parsed = sx_parse(sx_content)
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
except Exception:
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
# 4. Lexical rendered preview
if lexical_raw:
from bp.blog.ghost.lexical_renderer import render_lexical
try:
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
except Exception:
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
tctx = await get_template_context()
tctx.update(preview_ctx)
if not is_htmx_request():
html = await render_post_preview_page(tctx)
return await make_response(html)
else:
sx_src = await render_post_preview_oob(tctx)
return sx_response(sx_src)
@bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin
async def calendar_view(slug: str, calendar_id: int):
@@ -330,40 +362,6 @@ def register():
)
return sx_response(html)
@bp.get("/entries/")
@require_admin
async def entries(slug: str):
from ..services.entry_associations import get_post_entry_ids
from shared.models.calendars import Calendar
from sqlalchemy import select
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(post_id)
# Load ALL calendars (not just this post's calendars)
result = await g.s.execute(
select(Calendar)
.where(Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
all_calendars = result.scalars().all()
# Load entries and post for each calendar
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sx.page import get_template_context
from sx.sx_components import render_post_entries_page, render_post_entries_oob
tctx = await get_template_context()
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)
else:
sx_src = await render_post_entries_oob(tctx)
return sx_response(sx_src)
@bp.post("/entries/<int:entry_id>/toggle/")
@require_admin
async def toggle_entry(slug: str, entry_id: int):
@@ -416,36 +414,6 @@ def register():
return sx_response(admin_list + nav_entries_html)
@bp.get("/settings/")
@require_post_author
async def settings(slug: str):
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
from shared.sx.page import get_template_context
from sx.sx_components import render_post_settings_page, render_post_settings_oob
tctx = await get_template_context()
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)
else:
sx_src = await render_post_settings_oob(tctx)
return sx_response(sx_src)
@bp.post("/settings/")
@require_post_author
async def settings_save(slug: str):
@@ -500,7 +468,7 @@ def register():
except OptimisticLockError:
from urllib.parse import quote
return redirect(
host_url(url_for("blog.post.admin.settings", slug=slug))
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -511,46 +479,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# Redirect using the (possibly new) slug
return redirect(host_url(url_for("blog.post.admin.settings", slug=post.slug)) + "?saved=1")
@bp.get("/edit/")
@require_post_author
async def edit(slug: str):
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from sqlalchemy.orm import selectinload
from shared.infrastructure.data_client import fetch_data
post_id = g.post_data["post"]["id"]
post = (await g.s.execute(
sa_select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.tags))
)).scalar_one_or_none()
ghost_post = _post_to_edit_dict(post) if post else {}
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
# Newsletters live in db_account — fetch via HTTP
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sx.page import get_template_context
from sx.sx_components import render_post_edit_page, render_post_edit_oob
tctx = await get_template_context()
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)
else:
sx_src = await render_post_edit_oob(tctx)
return sx_response(sx_src)
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1")
@bp.post("/edit/")
@require_post_author
@@ -575,11 +504,11 @@ def register():
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
ok, reason = validate_lexical(lexical_doc)
if not ok:
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
@@ -615,7 +544,7 @@ def register():
)
except OptimisticLockError:
return redirect(
host_url(url_for("blog.post.admin.edit", slug=slug))
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug))
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
)
@@ -631,7 +560,7 @@ def register():
await invalidate_tag_cache("post.post_detail")
# Redirect to GET (PRG pattern) — use post.slug in case it changed
redirect_url = host_url(url_for("blog.post.admin.edit", slug=post.slug)) + "?saved=1"
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)

View File

@@ -32,25 +32,21 @@ async def _visible_snippets(session):
def register():
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
@bp.get("/")
@require_login
async def list_snippets():
"""List snippets visible to the current user."""
@bp.before_request
async def _prepare_page_data():
if "defpage_" not in (request.endpoint or ""):
return
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
from shared.sx.page import get_template_context
from sx.sx_components import render_snippets_page, render_snippets_oob
from sx.sx_components import _snippets_main_panel_sx
tctx = await get_template_context()
tctx["snippets"] = snippets
tctx["is_admin"] = is_admin
if not is_htmx_request():
html = await render_snippets_page(tctx)
return await make_response(html)
else:
sx_src = await render_snippets_oob(tctx)
return sx_response(sx_src)
g.snippets_content = _snippets_main_panel_sx(tctx)
from shared.sx.pages import mount_pages
mount_pages(bp, "blog", names=["snippets-page"])
@bp.delete("/<int:snippet_id>/")
@require_login

View File

@@ -26,6 +26,10 @@ from shared.sx.helpers import (
search_mobile_sx,
search_desktop_sx,
full_page_sx,
mobile_menu_sx,
mobile_root_nav_sx,
post_mobile_nav_sx,
post_admin_mobile_nav_sx,
)
# Load blog service .sx component definitions + handler definitions
@@ -76,6 +80,15 @@ def _post_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
def _post_admin_mobile_menu(ctx: dict, selected: str = "") -> str:
"""Full mobile menu for any post admin page (admin + post + root)."""
slug = (ctx.get("post") or {}).get("slug", "")
return mobile_menu_sx(
post_admin_mobile_nav_sx(ctx, slug, selected),
post_mobile_nav_sx(ctx),
mobile_root_nav_sx(ctx),
)
# ---------------------------------------------------------------------------
# Settings header (root-header-child -> root-settings-header-child)
@@ -85,7 +98,7 @@ def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Settings header row with admin icon and nav links (sx)."""
from quart import url_for as qurl
settings_href = qurl("settings.home")
settings_href = qurl("settings.defpage_settings_home")
label_sx = sx_call("blog-admin-label")
nav_sx = _settings_nav_sx(ctx)
@@ -107,10 +120,10 @@ def _settings_nav_sx(ctx: dict) -> str:
parts = []
for endpoint, icon, label in [
("menu_items.list_menu_items", "bars", "Menu Items"),
("snippets.list_snippets", "puzzle-piece", "Snippets"),
("blog.tag_groups_admin.index", "tags", "Tag Groups"),
("settings.cache", "refresh", "Cache"),
("menu_items.defpage_menu_items_page", "bars", "Menu Items"),
("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"),
("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"),
("settings.defpage_cache_page", "refresh", "Cache"),
]:
href = qurl(endpoint)
parts.append(sx_call("nav-link",
@@ -679,7 +692,7 @@ def _post_main_panel_sx(ctx: dict) -> str:
if post.get("status") == "draft":
edit_sx = ""
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
edit_href = qurl("blog.post.admin.edit", slug=slug)
edit_href = qurl("blog.post.admin.defpage_post_edit", slug=slug)
edit_sx = sx_call("blog-detail-edit-link",
href=edit_href, hx_select=hx_select,
)
@@ -951,7 +964,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
edit_href = qurl("blog.tag_groups_admin.edit", id=g_id)
edit_href = qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id)
if g_fi:
icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name)
@@ -1053,7 +1066,7 @@ async def render_home_page(ctx: dict) -> str:
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
content = _home_main_panel_sx(ctx)
meta = _post_meta_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
return full_page_sx(ctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
@@ -1088,9 +1101,8 @@ async def render_blog_oob(ctx: dict) -> str:
content = _blog_main_panel_sx(ctx)
aside = _blog_aside_sx(ctx)
filter_sx = _blog_filter_sx(ctx)
nav = ctx.get("nav_sx", "") or ""
return oob_page_sx(oobs=header_oob, content=content, aside=aside,
filter=filter_sx, menu=nav)
filter=filter_sx)
async def render_blog_cards(ctx: dict) -> str:
@@ -1304,15 +1316,6 @@ async def render_new_post_page(ctx: dict) -> str:
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_new_post_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
content = ctx.get("editor_html", "")
return oob_page_sx(oobs=header_oob, content=content)
# ---- Post detail ----
async def render_post_page(ctx: dict) -> str:
@@ -1321,7 +1324,7 @@ async def render_post_page(ctx: dict) -> str:
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
content = _post_main_panel_sx(ctx)
meta = _post_meta_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
return full_page_sx(ctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
@@ -1332,35 +1335,14 @@ async def render_post_oob(ctx: dict) -> str:
rows = "(<> " + root_hdr + " " + post_hdr + ")"
post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _post_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
oobs = post_oob
return oob_page_sx(oobs=oobs, content=content, menu=menu)
# ---- Post admin ----
async def render_post_admin_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _post_admin_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
return full_page_sx(ctx, header_rows=header_rows, content=content,
menu=menu)
async def render_post_admin_oob(ctx: dict) -> str:
post_hdr_oob = _post_header_sx(ctx, oob=True)
admin_oob = _oob_header_sx("post-header-child", "post-admin-header-child",
_post_admin_header_sx(ctx))
content = _post_admin_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
oobs = "(<> " + post_hdr_oob + " " + admin_oob + ")"
return oob_page_sx(oobs=oobs, content=content, menu=menu)
# ---- Post data ----
# ===========================================================================
def _post_data_content_sx(ctx: dict) -> str:
"""Build post data inspector panel natively (replaces _types/post_data/_main_panel.html)."""
@@ -1478,22 +1460,7 @@ def _post_data_content_sx(ctx: dict) -> str:
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 = _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 = _post_data_content_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Post preview ----
# ===========================================================================
def _preview_main_panel_sx(ctx: dict) -> str:
"""Build the preview panel with 4 expandable sections."""
@@ -1540,22 +1507,7 @@ def _preview_main_panel_sx(ctx: dict) -> str:
return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))
async def render_post_preview_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="preview")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _preview_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_preview_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="preview")
content = _preview_main_panel_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Post entries ----
# ===========================================================================
def _post_entries_content_sx(ctx: dict) -> str:
"""Build post entries panel natively (replaces _types/post_entries/_main_panel.html)."""
@@ -1613,21 +1565,6 @@ def _post_entries_content_sx(ctx: dict) -> str:
)
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 = _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 = _post_entries_content_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Calendar view (for entries browser) ----
def render_calendar_view(
@@ -2045,22 +1982,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
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 = _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 = _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)."""
@@ -2195,189 +2117,17 @@ def _post_settings_content_sx(ctx: dict) -> str:
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 = _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 = _post_settings_content_sx(ctx)
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ===========================================================================
# ===========================================================================
# ---- Settings home ----
async def render_settings_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")"
content = _settings_main_panel_sx(ctx)
menu = _settings_nav_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content,
menu=menu)
async def render_settings_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "root-settings-header-child", rows)
content = _settings_main_panel_sx(ctx)
menu = _settings_nav_sx(ctx)
return oob_page_sx(oobs=header_oob, content=content, menu=menu)
# ---- Cache ----
async def render_cache_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
cache_hdr = _sub_settings_header_sx(
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + cache_hdr + ")"
content = _cache_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_cache_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
cache_hdr = _sub_settings_header_sx(
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
cache_oob = _oob_header_sx("root-settings-header-child", "cache-header-child",
cache_hdr)
content = _cache_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + cache_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Snippets ----
async def render_snippets_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
snippets_hdr = _sub_settings_header_sx(
"snippets-row", "snippets-header-child",
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + snippets_hdr + ")"
content = _snippets_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_snippets_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
snippets_hdr = _sub_settings_header_sx(
"snippets-row", "snippets-header-child",
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
)
snippets_oob = _oob_header_sx("root-settings-header-child", "snippets-header-child",
snippets_hdr)
content = _snippets_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + snippets_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Menu items ----
async def render_menu_items_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
mi_hdr = _sub_settings_header_sx(
"menu_items-row", "menu_items-header-child",
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + mi_hdr + ")"
content = _menu_items_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_menu_items_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
mi_hdr = _sub_settings_header_sx(
"menu_items-row", "menu_items-header-child",
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
)
mi_oob = _oob_header_sx("root-settings-header-child", "menu_items-header-child",
mi_hdr)
content = _menu_items_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + mi_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Tag groups ----
async def render_tag_groups_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_tag_groups_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
)
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
tg_hdr)
content = _tag_groups_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Tag group edit ----
async def render_tag_group_edit_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_edit_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_tag_group_edit_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
)
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
tg_hdr)
content = _tag_groups_edit_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ===========================================================================
# ===========================================================================
# ===========================================================================
# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers

278
blog/sxc/pages/__init__.py Normal file
View File

@@ -0,0 +1,278 @@
"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_blog_pages() -> None:
"""Register blog-specific layouts, page helpers, and load page definitions."""
_register_blog_layouts()
_register_blog_helpers()
_load_blog_page_files()
def _load_blog_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "blog")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_blog_layouts() -> None:
from shared.sx.layouts import register_custom_layout
# :blog — root + blog header (for new-post, new-page)
register_custom_layout("blog", _blog_full, _blog_oob)
# :blog-settings — root + settings header (with settings nav menu)
register_custom_layout("blog-settings", _settings_full, _settings_oob,
mobile_fn=_settings_mobile)
# Sub-settings layouts (root + settings + sub header)
register_custom_layout("blog-cache", _cache_full, _cache_oob)
register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
register_custom_layout("blog-tag-group-edit",
_tag_group_edit_full, _tag_group_edit_oob)
# --- Blog layout (root + blog header) ---
def _blog_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
return "(<> " + root_hdr + " " + blog_hdr + ")"
def _blog_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _blog_header_sx
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
return oob_header_sx("root-header-child", "blog-header-child", rows)
# --- Settings layout (root + settings header) ---
def _settings_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
return "(<> " + root_hdr + " " + settings_hdr + ")"
def _settings_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, oob_header_sx
from sx.sx_components import _settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
def _settings_mobile(ctx: dict, **kw: Any) -> str:
from sx.sx_components import _settings_nav_sx
return _settings_nav_sx(ctx)
# --- Sub-settings helpers ---
def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
endpoint: str, icon: str, label: str) -> str:
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
from quart import url_for as qurl
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx(row_id, child_id,
qurl(endpoint), icon, label, ctx)
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# --- Cache ---
def _cache_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache")
def _cache_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
"settings.defpage_cache_page", "refresh", "Cache")
# --- Snippets ---
def _snippets_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
def _snippets_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
# --- Menu Items ---
def _menu_items_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
def _menu_items_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
# --- Tag Groups ---
def _tag_groups_full(ctx: dict, **kw: Any) -> str:
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
# --- Tag Group Edit ---
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import root_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
from quart import request
g_id = (request.view_args or {}).get("id")
from quart import url_for as qurl
from shared.sx.helpers import oob_header_sx
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
"tags", "Tag Groups", ctx)
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
# ---------------------------------------------------------------------------
# Page helpers (sync functions available in .sx defpage expressions)
# ---------------------------------------------------------------------------
def _register_blog_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("blog", {
"editor-content": _h_editor_content,
"editor-page-content": _h_editor_page_content,
"post-admin-content": _h_post_admin_content,
"post-data-content": _h_post_data_content,
"post-preview-content": _h_post_preview_content,
"post-entries-content": _h_post_entries_content,
"post-settings-content": _h_post_settings_content,
"post-edit-content": _h_post_edit_content,
"settings-content": _h_settings_content,
"cache-content": _h_cache_content,
"snippets-content": _h_snippets_content,
"menu-items-content": _h_menu_items_content,
"tag-groups-content": _h_tag_groups_content,
"tag-group-edit-content": _h_tag_group_edit_content,
})
def _h_editor_content():
from quart import g
return getattr(g, "editor_content", "")
def _h_editor_page_content():
from quart import g
return getattr(g, "editor_page_content", "")
def _h_post_admin_content():
from quart import g
return getattr(g, "post_admin_content", "")
def _h_post_data_content():
from quart import g
return getattr(g, "post_data_content", "")
def _h_post_preview_content():
from quart import g
return getattr(g, "post_preview_content", "")
def _h_post_entries_content():
from quart import g
return getattr(g, "post_entries_content", "")
def _h_post_settings_content():
from quart import g
return getattr(g, "post_settings_content", "")
def _h_post_edit_content():
from quart import g
return getattr(g, "post_edit_content", "")
def _h_settings_content():
from quart import g
return getattr(g, "settings_content", "")
def _h_cache_content():
from quart import g
return getattr(g, "cache_content", "")
def _h_snippets_content():
from quart import g
return getattr(g, "snippets_content", "")
def _h_menu_items_content():
from quart import g
return getattr(g, "menu_items_content", "")
def _h_tag_groups_content():
from quart import g
return getattr(g, "tag_groups_content", "")
def _h_tag_group_edit_content():
from quart import g
return getattr(g, "tag_group_edit_content", "")

98
blog/sxc/pages/blog.sx Normal file
View File

@@ -0,0 +1,98 @@
; Blog app defpage declarations
; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
; --- New post/page editors ---
(defpage new-post
:path "/new/"
:auth :admin
:layout :blog
:content (editor-content))
(defpage new-page
:path "/new-page/"
:auth :admin
:layout :blog
:content (editor-page-content))
; --- Post admin pages (nested under /<slug>/admin/) ---
(defpage post-admin
:path "/"
:auth :admin
:layout (:post-admin :selected "admin")
:content (post-admin-content))
(defpage post-data
:path "/data/"
:auth :admin
:layout (:post-admin :selected "data")
:content (post-data-content))
(defpage post-preview
:path "/preview/"
:auth :admin
:layout (:post-admin :selected "preview")
:content (post-preview-content))
(defpage post-entries
:path "/entries/"
:auth :admin
:layout (:post-admin :selected "entries")
:content (post-entries-content))
(defpage post-settings
:path "/settings/"
:auth :post_author
:layout (:post-admin :selected "settings")
:content (post-settings-content))
(defpage post-edit
:path "/edit/"
:auth :post_author
:layout (:post-admin :selected "edit")
:content (post-edit-content))
; --- Settings pages ---
(defpage settings-home
:path "/"
:auth :admin
:layout :blog-settings
:content (settings-content))
(defpage cache-page
:path "/cache/"
:auth :admin
:layout :blog-cache
:content (cache-content))
; --- Snippets ---
(defpage snippets-page
:path "/"
:auth :login
:layout :blog-snippets
:content (snippets-content))
; --- Menu Items ---
(defpage menu-items-page
:path "/"
:auth :admin
:layout :blog-menu-items
:content (menu-items-content))
; --- Tag Groups ---
(defpage tag-groups-page
:path "/"
:auth :admin
:layout :blog-tag-groups
:content (tag-groups-content))
(defpage tag-group-edit
:path "/<int:id>/"
:auth :admin
:layout :blog-tag-group-edit
:content (tag-group-edit-content))

View File

@@ -1,7 +1,7 @@
{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
<div class="flex flex-wrap gap-2 px-4 py-3">
{% if has_access('blog.new_post') %}
{% set new_href = url_for('blog.new_post')|host %}
{% if has_access('blog.defpage_new_post') %}
{% set new_href = url_for('blog.defpage_new_post')|host %}
<a
href="{{ new_href }}"
sx-get="{{ new_href }}"
@@ -14,7 +14,7 @@
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
{% set new_page_href = url_for('blog.new_page')|host %}
{% set new_page_href = url_for('blog.defpage_new_page')|host %}
<a
href="{{ new_page_href }}"
sx-get="{{ new_page_href }}"

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-edit-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }}
{{ admin_nav_item(url_for('blog.tag_groups_admin.defpage_tag_group_edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours, aclass='') }}
{{ admin_nav_item(url_for('blog.tag_groups_admin.defpage_tag_groups_page'), 'tags', 'Tag Groups', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}

View File

@@ -42,7 +42,7 @@
</div>
{% endif %}
<div class="flex-1">
<a href="{{ url_for('blog.tag_groups_admin.edit', id=group.id) }}"
<a href="{{ url_for('blog.tag_groups_admin.defpage_tag_group_edit', id=group.id) }}"
class="font-medium text-stone-800 hover:underline">
{{ group.name }}
</a>

View File

@@ -2,7 +2,7 @@
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-stone-800">Drafts</h2>
{% set new_href = url_for('blog.new_post')|host %}
{% set new_href = url_for('blog.defpage_new_post')|host %}
<a
href="{{ new_href }}"
sx-get="{{ new_href }}"
@@ -19,7 +19,7 @@
{% if drafts %}
<div class="space-y-3">
{% for draft in drafts %}
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
{% set edit_href = url_for('blog.post.admin.defpage_post_edit', slug=draft.slug)|host %}
<a
href="{{ edit_href }}"
sx-disable

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='menu_items-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours, aclass='') }}
{{ admin_nav_item(url_for('menu_items.defpage_menu_items_page'), 'bars', 'Menu Items', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}

View File

@@ -9,7 +9,7 @@
{% endif %}
{% set is_admin = (g.get("rights") or {}).get("admin") %}
{% if is_admin or (g.user and post.user_id == g.user.id) %}
{% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
{% set edit_href = url_for('blog.post.admin.defpage_post_edit', slug=post.slug)|host %}
<a
href="{{ edit_href }}"
sx-get="{{ edit_href }}"

View File

@@ -8,8 +8,8 @@
{% endif %}
{# Admin link #}
{% if post and has_access('blog.post.admin.admin') %}
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% if post and has_access('blog.post.admin.defpage_post_admin') %}
{% call links.link(url_for('blog.post.admin.defpage_post_admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i>
{% endcall %}
{% endif %}

View File

@@ -14,15 +14,15 @@
payments
</a>
</div>
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% call links.link(url_for('blog.post.admin.defpage_post_entries', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
entries
{% endcall %}
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% call links.link(url_for('blog.post.admin.defpage_post_data', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
data
{% endcall %}
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
edit
{% endcall %}
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
settings
{% endcall %}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post-admin-row', oob=oob) %}
{% call links.link(
url_for('blog.post.admin.admin', slug=post.slug),
url_for('blog.post.admin.defpage_post_admin', slug=post.slug),
hx_select_search) %}
{{ links.admin() }}
{% endcall %}

View File

@@ -3,7 +3,7 @@
{% block ___app_title %}
{% import 'macros/links.html' as links %}
{% call links.menu_row() %}
{% call links.link(url_for('blog.post.admin.data', slug=post.slug), hx_select_search) %}
{% call links.link(url_for('blog.post.admin.defpage_post_data', slug=post.slug), hx_select_search) %}
<i class="fa fa-database" aria-hidden="true"></i>
<div>
data

View File

@@ -1,5 +1,5 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i>
settings
{% endcall %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_edit-row', oob=oob) %}
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search) %}
{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search) %}
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
<div>
edit

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_entries-row', oob=oob) %}
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %}
{% call links.link(url_for('blog.post.admin.defpage_post_entries', slug=post.slug), hx_select_search) %}
<i class="fa fa-clock" aria-hidden="true"></i>
<div>
entries

View File

@@ -1,5 +1,5 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.admin.edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% call links.link(url_for('blog.post.admin.defpage_post_edit', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-pen-to-square" aria-hidden="true"></i>
edit
{% endcall %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post_settings-row', oob=oob) %}
{% call links.link(url_for('blog.post.admin.settings', slug=post.slug), hx_select_search) %}
{% call links.link(url_for('blog.post.admin.defpage_post_settings', slug=post.slug), hx_select_search) %}
<i class="fa fa-cog" aria-hidden="true"></i>
<div>
settings

View File

@@ -1,5 +1,5 @@
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours) }}
{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours) }}
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours) }}
{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours) }}
{{ admin_nav_item(url_for('menu_items.defpage_menu_items_page'), 'bars', 'Menu Items', select_colours) }}
{{ admin_nav_item(url_for('snippets.defpage_snippets_page'), 'puzzle-piece', 'Snippets', select_colours) }}
{{ admin_nav_item(url_for('blog.tag_groups_admin.defpage_tag_groups_page'), 'tags', 'Tag Groups', select_colours) }}
{{ admin_nav_item(url_for('settings.defpage_cache_page'), 'refresh', 'Cache', select_colours) }}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='cache-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('settings.cache'), 'refresh', 'Cache', select_colours, aclass='') }}
{{ admin_nav_item(url_for('settings.defpage_cache_page'), 'refresh', 'Cache', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='root-settings-row', oob=oob) %}
{% call links.link(url_for('settings.home'), hx_select_search) %}
{% call links.link(url_for('settings.defpage_settings_home'), hx_select_search) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}

View File

@@ -2,7 +2,7 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='snippets-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('snippets.list_snippets'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }}
{{ admin_nav_item(url_for('snippets.defpage_snippets_page'), 'puzzle-piece', 'Snippets', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}