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))