diff --git a/blog/services/__init__.py b/blog/services/__init__.py
index f3b1c94..d00fe83 100644
--- a/blog/services/__init__.py
+++ b/blog/services/__init__.py
@@ -76,3 +76,6 @@ def register_domain_services() -> None:
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()
+
+ from .blog_page import BlogPageService
+ services.register("blog_page", BlogPageService())
diff --git a/blog/services/blog_page.py b/blog/services/blog_page.py
new file mode 100644
index 0000000..da57df2
--- /dev/null
+++ b/blog/services/blog_page.py
@@ -0,0 +1,234 @@
+"""Blog page data service — provides serialized dicts for .sx defpages."""
+from __future__ import annotations
+
+
+class BlogPageService:
+ """Service for blog page data, callable via (service "blog-page" ...)."""
+
+ async def cache_data(self, session, **kw):
+ from quart import url_for as qurl
+ from shared.browser.app.csrf import generate_csrf_token
+ return {
+ "clear_url": qurl("settings.cache_clear"),
+ "csrf": generate_csrf_token(),
+ }
+
+ async def snippets_data(self, session, **kw):
+ from quart import g, url_for as qurl
+ from sqlalchemy import select, or_
+ from models import Snippet
+ from shared.browser.app.csrf import generate_csrf_token
+
+ uid = g.user.id
+ is_admin = g.rights.get("admin")
+ csrf = generate_csrf_token()
+ filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
+ if is_admin:
+ filters.append(Snippet.visibility == "admin")
+ rows = (await session.execute(
+ select(Snippet).where(or_(*filters)).order_by(Snippet.name)
+ )).scalars().all()
+
+ snippets = []
+ for s in rows:
+ s_id = s.id
+ s_vis = s.visibility or "private"
+ s_uid = s.user_id
+ owner = "You" if s_uid == uid else f"User #{s_uid}"
+ can_delete = s_uid == uid or is_admin
+ d = {
+ "id": s_id,
+ "name": s.name or "",
+ "visibility": s_vis,
+ "owner": owner,
+ "can_delete": can_delete,
+ }
+ if is_admin:
+ d["patch_url"] = qurl("snippets.patch_visibility", snippet_id=s_id)
+ if can_delete:
+ d["delete_url"] = qurl("snippets.delete_snippet", snippet_id=s_id)
+ snippets.append(d)
+ return {
+ "snippets": snippets,
+ "is_admin": bool(is_admin),
+ "csrf": csrf,
+ }
+
+ async def menu_items_data(self, session, **kw):
+ from quart import url_for as qurl
+ from bp.menu_items.services.menu_items import get_all_menu_items
+ from shared.browser.app.csrf import generate_csrf_token
+
+ menu_items = await get_all_menu_items(session)
+ csrf = generate_csrf_token()
+ items = []
+ for mi in menu_items:
+ i_id = mi.id
+ label = mi.label or ""
+ fi = getattr(mi, "feature_image", None)
+ sort = mi.position or 0
+ items.append({
+ "id": i_id,
+ "label": label,
+ "url": mi.url or "",
+ "sort_order": sort,
+ "feature_image": fi,
+ "edit_url": qurl("menu_items.edit_menu_item", item_id=i_id),
+ "delete_url": qurl("menu_items.delete_menu_item_route", item_id=i_id),
+ })
+ return {
+ "menu_items": items,
+ "new_url": qurl("menu_items.new_menu_item"),
+ "csrf": csrf,
+ }
+
+ async def tag_groups_data(self, session, **kw):
+ from quart import url_for as qurl
+ from sqlalchemy import select
+ from models.tag_group import TagGroup
+ from bp.blog.admin.routes import _unassigned_tags
+ from shared.browser.app.csrf import generate_csrf_token
+
+ groups_rows = list(
+ (await session.execute(
+ select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
+ )).scalars()
+ )
+ unassigned = await _unassigned_tags(session)
+
+ groups = []
+ for g in groups_rows:
+ groups.append({
+ "id": g.id,
+ "name": g.name or "",
+ "slug": getattr(g, "slug", "") or "",
+ "feature_image": getattr(g, "feature_image", None),
+ "colour": getattr(g, "colour", None),
+ "sort_order": getattr(g, "sort_order", 0) or 0,
+ "edit_href": qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g.id),
+ })
+
+ unassigned_tags = []
+ for t in unassigned:
+ unassigned_tags.append({
+ "name": getattr(t, "name", "") if hasattr(t, "name") else t.get("name", ""),
+ })
+
+ return {
+ "groups": groups,
+ "unassigned_tags": unassigned_tags,
+ "create_url": qurl("blog.tag_groups_admin.create"),
+ "csrf": generate_csrf_token(),
+ }
+
+ async def tag_group_edit_data(self, session, *, id=None, **kw):
+ from quart import abort, url_for as qurl
+ from sqlalchemy import select
+ from models.tag_group import TagGroup, TagGroupTag
+ from models.ghost_content import Tag
+ from shared.browser.app.csrf import generate_csrf_token
+
+ tg = await session.get(TagGroup, id)
+ if not tg:
+ abort(404)
+
+ assigned_rows = list(
+ (await session.execute(
+ select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
+ )).scalars()
+ )
+ assigned_set = set(assigned_rows)
+
+ all_tags_rows = list(
+ (await session.execute(
+ select(Tag).where(
+ Tag.deleted_at.is_(None),
+ (Tag.visibility == "public") | (Tag.visibility.is_(None)),
+ ).order_by(Tag.name)
+ )).scalars()
+ )
+
+ all_tags = []
+ for t in all_tags_rows:
+ all_tags.append({
+ "id": t.id,
+ "name": getattr(t, "name", "") or "",
+ "feature_image": getattr(t, "feature_image", None),
+ "checked": t.id in assigned_set,
+ })
+
+ return {
+ "group": {
+ "id": tg.id,
+ "name": tg.name or "",
+ "colour": getattr(tg, "colour", "") or "",
+ "sort_order": getattr(tg, "sort_order", 0) or 0,
+ "feature_image": getattr(tg, "feature_image", "") or "",
+ },
+ "all_tags": all_tags,
+ "save_url": qurl("blog.tag_groups_admin.save", id=tg.id),
+ "delete_url": qurl("blog.tag_groups_admin.delete_group", id=tg.id),
+ "csrf": generate_csrf_token(),
+ }
+
+ async def post_admin_data(self, session, *, slug=None, **kw):
+ """Post admin panel — just needs post loaded into context."""
+ from quart import g
+ from sqlalchemy import select
+ from shared.models.page_config import PageConfig
+
+ # _ensure_post_data is called by before_request in defpage context
+ post = (g.post_data or {}).get("post", {})
+ features = {}
+ sumup_configured = False
+ if post.get("is_page"):
+ pc = (await session.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)
+ return {
+ "features": features,
+ "sumup_configured": sumup_configured,
+ }
+
+ async def preview_data(self, session, *, slug=None, **kw):
+ """Build preview data with prettified/rendered content."""
+ from quart import g
+ from models.ghost_content import Post
+ from sqlalchemy import select as sa_select
+
+ post_id = g.post_data["post"]["id"]
+ post = (await session.execute(
+ sa_select(Post).where(Post.id == post_id)
+ )).scalar_one_or_none()
+
+ result = {}
+ sx_content = getattr(post, "sx_content", None) or ""
+ if sx_content:
+ from shared.sx.prettify import sx_to_pretty_sx
+ result["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
+ result["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)
+ result["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
+ except Exception:
+ result["sx_rendered"] = "Error rendering sx"
+ if lexical_raw:
+ from bp.blog.ghost.lexical_renderer import render_lexical
+ try:
+ result["lex_rendered"] = render_lexical(lexical_raw)
+ except Exception:
+ result["lex_rendered"] = "Error rendering lexical"
+ return result
diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx
index 85d2f98..b78233d 100644
--- a/blog/sx/admin.sx
+++ b/blog/sx/admin.sx
@@ -169,3 +169,117 @@
(details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content)))
+
+;; ---------------------------------------------------------------------------
+;; Data-driven content defcomps (called from defpages with service data)
+;; ---------------------------------------------------------------------------
+
+;; Snippets — receives serialized snippet dicts from service
+(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
+ (~blog-snippets-panel
+ :list (if (empty? (or snippets (list)))
+ (~empty-state :icon "fa fa-puzzle-piece"
+ :message "No snippets yet. Create one from the blog editor.")
+ (~blog-snippets-list
+ :rows (map (lambda (s)
+ (let* ((badge-colours (dict
+ "private" "bg-stone-200 text-stone-700"
+ "shared" "bg-blue-100 text-blue-700"
+ "admin" "bg-amber-100 text-amber-700"))
+ (vis (or (get s "visibility") "private"))
+ (badge-cls (or (get badge-colours vis) "bg-stone-200 text-stone-700"))
+ (name (get s "name"))
+ (owner (get s "owner"))
+ (can-delete (get s "can_delete")))
+ (~blog-snippet-row
+ :name name :owner owner :badge-cls badge-cls :visibility vis
+ :extra (<>
+ (when is-admin
+ (~blog-snippet-visibility-select
+ :patch-url (get s "patch_url")
+ :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
+ :options (<>
+ (~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
+ (~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
+ (~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
+ (when can-delete
+ (~delete-btn
+ :url (get s "delete_url")
+ :trigger-target "#snippets-list"
+ :title "Delete snippet?"
+ :text (str "Delete \u201c" name "\u201d?")
+ :sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
+ :cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
+ (or snippets (list)))))))
+
+;; Menu Items — receives serialized menu item dicts from service
+(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
+ (~blog-menu-items-panel
+ :new-url new-url
+ :list (if (empty? (or menu-items (list)))
+ (~empty-state :icon "fa fa-inbox"
+ :message "No menu items yet. Add one to get started!")
+ (~blog-menu-items-list
+ :rows (map (lambda (mi)
+ (~blog-menu-item-row
+ :img (~img-or-placeholder
+ :src (get mi "feature_image") :alt (get mi "label")
+ :size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
+ :label (get mi "label")
+ :slug (get mi "url")
+ :sort-order (str (or (get mi "sort_order") 0))
+ :edit-url (get mi "edit_url")
+ :delete-url (get mi "delete_url")
+ :confirm-text (str "Remove " (get mi "label") " from the menu?")
+ :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
+ (or menu-items (list)))))))
+
+;; Tag Groups — receives serialized tag group data from service
+(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
+ (~blog-tag-groups-main
+ :form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
+ :groups (if (empty? (or groups (list)))
+ (~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
+ (~blog-tag-groups-list
+ :items (map (lambda (g)
+ (let* ((fi (get g "feature_image"))
+ (colour (get g "colour"))
+ (name (get g "name"))
+ (initial (slice (or name "?") 0 1))
+ (icon (if fi
+ (~blog-tag-group-icon-image :src fi :name name)
+ (~blog-tag-group-icon-color
+ :style (if colour (str "background:" colour) "background:#e7e5e4")
+ :initial initial))))
+ (~blog-tag-group-li
+ :icon icon
+ :edit-href (get g "edit_href")
+ :name name
+ :slug (or (get g "slug") "")
+ :sort-order (or (get g "sort_order") 0))))
+ (or groups (list)))))
+ :unassigned (when (not (empty? (or unassigned-tags (list))))
+ (~blog-unassigned-tags
+ :heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
+ :spans (map (lambda (t)
+ (~blog-unassigned-tag :name (get t "name")))
+ (or unassigned-tags (list)))))))
+
+;; Tag Group Edit — receives serialized tag group + tags from service
+(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
+ (~blog-tag-group-edit-main
+ :edit-form (~blog-tag-group-edit-form
+ :save-url save-url :csrf csrf
+ :name (get group "name")
+ :colour (get group "colour")
+ :sort-order (get group "sort_order")
+ :feature-image (get group "feature_image")
+ :tags (map (lambda (t)
+ (~blog-tag-checkbox
+ :tag-id (get t "id")
+ :checked (get t "checked")
+ :img (when (get t "feature_image")
+ (~blog-tag-checkbox-image :src (get t "feature_image")))
+ :name (get t "name")))
+ (or all-tags (list))))
+ :delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
diff --git a/blog/sxc/pages/__init__.py b/blog/sxc/pages/__init__.py
index dbecb2d..623019b 100644
--- a/blog/sxc/pages/__init__.py
+++ b/blog/sxc/pages/__init__.py
@@ -289,12 +289,6 @@ def _register_blog_helpers() -> None:
"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,
})
@@ -471,104 +465,3 @@ async def _h_post_edit_content(slug=None, **kw):
return await _post_edit_content_sx(tctx)
-# --- Settings helpers ---
-
-async def _h_settings_content(**kw):
- from shared.sx.page import get_template_context
- from sx.sx_components import _settings_main_panel_sx
- tctx = await get_template_context()
- return _settings_main_panel_sx(tctx)
-
-
-async def _h_cache_content(**kw):
- from shared.sx.page import get_template_context
- from sx.sx_components import _cache_main_panel_sx
- tctx = await get_template_context()
- return await _cache_main_panel_sx(tctx)
-
-
-# --- Snippets helper ---
-
-async def _h_snippets_content(**kw):
- from quart import g
- from sqlalchemy import select, or_
- from models import Snippet
- uid = g.user.id
- is_admin = g.rights.get("admin")
- filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
- if is_admin:
- filters.append(Snippet.visibility == "admin")
- rows = (await g.s.execute(
- select(Snippet).where(or_(*filters)).order_by(Snippet.name)
- )).scalars().all()
- from shared.sx.page import get_template_context
- from sx.sx_components import _snippets_main_panel_sx
- tctx = await get_template_context()
- tctx["snippets"] = rows
- tctx["is_admin"] = is_admin
- return await _snippets_main_panel_sx(tctx)
-
-
-# --- Menu Items helper ---
-
-async def _h_menu_items_content(**kw):
- from quart import g
- from bp.menu_items.services.menu_items import get_all_menu_items
- menu_items = await get_all_menu_items(g.s)
- from shared.sx.page import get_template_context
- from sx.sx_components import _menu_items_main_panel_sx
- tctx = await get_template_context()
- tctx["menu_items"] = menu_items
- return await _menu_items_main_panel_sx(tctx)
-
-
-# --- Tag Groups helpers ---
-
-async def _h_tag_groups_content(**kw):
- from quart import g
- from sqlalchemy import select
- from models.tag_group import TagGroup
- from bp.blog.admin.routes import _unassigned_tags
- 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})
- return await _tag_groups_main_panel_sx(tctx)
-
-
-async def _h_tag_group_edit_content(id=None, **kw):
- from quart import g, abort
- from sqlalchemy import select
- from models.tag_group import TagGroup, TagGroupTag
- from models.ghost_content import Tag
- tg = await g.s.get(TagGroup, id)
- if not tg:
- abort(404)
- assigned_rows = list(
- (await g.s.execute(
- select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == 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),
- })
- return await _tag_groups_edit_main_panel_sx(tctx)
diff --git a/blog/sxc/pages/blog.sx b/blog/sxc/pages/blog.sx
index 21a816e..b3998ae 100644
--- a/blog/sxc/pages/blog.sx
+++ b/blog/sxc/pages/blog.sx
@@ -59,13 +59,14 @@
:path "/settings/"
:auth :admin
:layout :blog-settings
- :content (settings-content))
+ :content (div :class "max-w-2xl mx-auto px-4 py-6"))
(defpage cache-page
:path "/settings/cache/"
:auth :admin
:layout :blog-cache
- :content (cache-content))
+ :data (service "blog-page" "cache-data")
+ :content (~blog-cache-panel :clear-url clear-url :csrf csrf))
; --- Snippets ---
@@ -73,7 +74,9 @@
:path "/settings/snippets/"
:auth :login
:layout :blog-snippets
- :content (snippets-content))
+ :data (service "blog-page" "snippets-data")
+ :content (~blog-snippets-content
+ :snippets snippets :is-admin is-admin :csrf csrf))
; --- Menu Items ---
@@ -81,7 +84,9 @@
:path "/settings/menu_items/"
:auth :admin
:layout :blog-menu-items
- :content (menu-items-content))
+ :data (service "blog-page" "menu-items-data")
+ :content (~blog-menu-items-content
+ :menu-items menu-items :new-url new-url :csrf csrf))
; --- Tag Groups ---
@@ -89,10 +94,16 @@
:path "/settings/tag-groups/"
:auth :admin
:layout :blog-tag-groups
- :content (tag-groups-content))
+ :data (service "blog-page" "tag-groups-data")
+ :content (~blog-tag-groups-content
+ :groups groups :unassigned-tags unassigned-tags
+ :create-url create-url :csrf csrf))
(defpage tag-group-edit
:path "/settings/tag-groups//"
:auth :admin
:layout :blog-tag-group-edit
- :content (tag-group-edit-content id))
+ :data (service "blog-page" "tag-group-edit-data" :id id)
+ :content (~blog-tag-group-edit-content
+ :group group :all-tags all-tags
+ :save-url save-url :delete-url delete-url :csrf csrf))