+ {{ page.title }} +
+ + {# Feature badges #} + {% if page.features %} ++ Published: {{ page.published_at.strftime("%-d %b %Y at %H:%M") }} +
+ {% endif %} ++ {{ page.custom_excerpt or page.excerpt }} +
+ {% endif %} + +diff --git a/bp/blog/ghost/ghost_posts.py b/bp/blog/ghost/ghost_posts.py index 255ba75..7d7b48e 100644 --- a/bp/blog/ghost/ghost_posts.py +++ b/bp/blog/ghost/ghost_posts.py @@ -73,6 +73,35 @@ async def create_post( return resp.json()["posts"][0] +async def create_page( + title: str, + lexical_json: str, + status: str = "draft", + feature_image: str | None = None, + custom_excerpt: str | None = None, + feature_image_caption: str | None = None, +) -> dict: + """Create a new page in Ghost (via /pages/ endpoint). Returns the created page dict.""" + page_body: dict = { + "title": title, + "lexical": lexical_json, + "mobiledoc": None, + "status": status, + } + if feature_image: + page_body["feature_image"] = feature_image + if custom_excerpt: + page_body["custom_excerpt"] = custom_excerpt + if feature_image_caption is not None: + page_body["feature_image_caption"] = feature_image_caption + payload = {"pages": [page_body]} + url = f"{GHOST_ADMIN_API_URL}/pages/" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(url, json=payload, headers=_auth_header()) + _check(resp) + return resp.json()["pages"][0] + + async def update_post( ghost_id: str, lexical_json: str, diff --git a/bp/blog/ghost/ghost_sync.py b/bp/blog/ghost/ghost_sync.py index bc1d010..8ad456d 100644 --- a/bp/blog/ghost/ghost_sync.py +++ b/bp/blog/ghost/ghost_sync.py @@ -13,6 +13,7 @@ from sqlalchemy.orm.attributes import flag_modified # for non-Mutable JSON colu from models.ghost_content import ( Post, Author, Tag, PostAuthor, PostTag ) +from models.page_config import PageConfig # User-centric membership models from models import User @@ -238,6 +239,15 @@ async def _upsert_post(sess: AsyncSession, gp: Dict[str, Any], author_map: Dict[ tt = tag_map[t["id"]] sess.add(PostTag(post_id=obj.id, tag_id=tt.id, sort_order=idx)) + # Auto-create PageConfig for pages + if obj.is_page: + existing_pc = (await sess.execute( + select(PageConfig).where(PageConfig.post_id == obj.id) + )).scalar_one_or_none() + if existing_pc is None: + sess.add(PageConfig(post_id=obj.id, features={})) + await sess.flush() + return obj async def _ghost_find_member_by_email(email: str) -> Optional[dict]: diff --git a/bp/blog/ghost_db.py b/bp/blog/ghost_db.py index 19ec0d5..3c0642b 100644 --- a/bp/blog/ghost_db.py +++ b/bp/blog/ghost_db.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload from models.ghost_content import Post, Author, Tag, PostTag +from models.page_config import PageConfig from models.tag_group import TagGroup, TagGroupTag @@ -235,6 +236,78 @@ class DBClient: return posts, pagination + async def list_pages( + self, + limit: int = 10, + page: int = 1, + search: Optional[str] = None, + ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """ + List published pages (is_page=True) with their PageConfig eagerly loaded. + Returns (pages, pagination). + """ + base_filters = [ + Post.deleted_at.is_(None), + Post.status == "published", + Post.is_page.is_(True), + ] + + q = select(Post).where(*base_filters) + + if search: + term = f"%{search.strip().lower()}%" + q = q.where( + or_( + func.lower(func.coalesce(Post.title, "")).like(term), + func.lower(func.coalesce(Post.excerpt, "")).like(term), + func.lower(func.coalesce(Post.plaintext, "")).like(term), + ) + ) + + q = q.order_by(desc(Post.published_at)) + + if page < 1: + page = 1 + offset_val = (page - 1) * limit + + q_no_limit = q.with_only_columns(Post.id).order_by(None) + count_q = select(func.count()).select_from(q_no_limit.subquery()) + total = int((await self.sess.execute(count_q)).scalar() or 0) + + q = ( + q.options( + joinedload(Post.primary_author), + joinedload(Post.primary_tag), + selectinload(Post.authors), + selectinload(Post.tags), + joinedload(Post.page_config), + ) + .limit(limit) + .offset(offset_val) + ) + + rows: List[Post] = list((await self.sess.execute(q)).scalars()) + + def _page_to_public(p: Post) -> Dict[str, Any]: + d = _post_to_public(p) + pc = p.page_config + d["features"] = pc.features if pc else {} + return d + + pages_list = [_page_to_public(p) for p in rows] + + pages_total = (total + limit - 1) // limit if limit else 1 + pagination = { + "page": page, + "limit": limit, + "pages": pages_total, + "total": total, + "next": page + 1 if page < pages_total else None, + "prev": page - 1 if page > 1 else None, + } + + return pages_list, pagination + async def posts_by_slug( self, slug: str, diff --git a/bp/blog/routes.py b/bp/blog/routes.py index 8fcf86b..eca9cf6 100644 --- a/bp/blog/routes.py +++ b/bp/blog/routes.py @@ -18,6 +18,7 @@ from .ghost_db import DBClient # adjust import path from db.session import get_session from .filters.qs import makeqs_factory, decode from .services.posts_data import posts_data +from .services.pages_data import pages_data from suma_browser.app.redis_cacher import cache_page, invalidate_tag_cache from suma_browser.app.utils.htmx import is_htmx_request @@ -81,7 +82,24 @@ def register(url_prefix, title): async def home(): q = decode() + content_type = request.args.get("type", "posts") + if content_type == "pages": + data = await pages_data(g.s, q.page, q.search) + context = { + **data, + "content_type": "pages", + "search": q.search, + } + if not is_htmx_request(): + html = await render_template("_types/blog/index.html", **context) + elif q.page > 1: + html = await render_template("_types/blog/_page_cards.html", **context) + else: + html = await render_template("_types/blog/_oob_elements.html", **context) + return await make_response(html) + + # Default: posts listing # Drafts filter requires login; ignore if not logged in show_drafts = bool(q.drafts and g.user) is_admin = bool((g.get("rights") or {}).get("admin")) @@ -99,6 +117,7 @@ def register(url_prefix, title): context = { **data, + "content_type": "posts", "selected_tags": q.selected_tags, "selected_authors": q.selected_authors, "selected_groups": q.selected_groups, @@ -196,6 +215,81 @@ def register(url_prefix, title): return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"]))) + @blogs_bp.get("/new-page/") + @require_admin + async def new_page(): + if not is_htmx_request(): + html = await render_template("_types/blog_new/index.html", is_page=True) + else: + html = await render_template("_types/blog_new/_oob_elements.html", is_page=True) + return await make_response(html) + + @blogs_bp.post("/new-page/") + @require_admin + async def new_page_save(): + from .ghost.ghost_posts import create_page + from .ghost.lexical_validator import validate_lexical + from .ghost.ghost_sync import sync_single_page + + form = await request.form + title = form.get("title", "").strip() or "Untitled" + lexical_raw = form.get("lexical", "") + status = form.get("status", "draft") + feature_image = form.get("feature_image", "").strip() + custom_excerpt = form.get("custom_excerpt", "").strip() + feature_image_caption = form.get("feature_image_caption", "").strip() + + # Validate + try: + lexical_doc = json.loads(lexical_raw) + except (json.JSONDecodeError, TypeError): + html = await render_template( + "_types/blog_new/index.html", + save_error="Invalid JSON in editor content.", + is_page=True, + ) + return await make_response(html, 400) + + ok, reason = validate_lexical(lexical_doc) + if not ok: + html = await render_template( + "_types/blog_new/index.html", + save_error=reason, + is_page=True, + ) + return await make_response(html, 400) + + # Create in Ghost (as page) + ghost_page = await create_page( + title=title, + lexical_json=lexical_raw, + status=status, + feature_image=feature_image or None, + custom_excerpt=custom_excerpt or None, + feature_image_caption=feature_image_caption or None, + ) + + # Sync to local DB (uses pages endpoint) + await sync_single_page(g.s, ghost_page["id"]) + await g.s.flush() + + # Set user_id on the newly created page + from models.ghost_content import Post + from sqlalchemy import select + local_post = (await g.s.execute( + select(Post).where(Post.ghost_id == ghost_page["id"]) + )).scalar_one_or_none() + if local_post and local_post.user_id is None: + local_post.user_id = g.user.id + await g.s.flush() + + # Clear blog listing cache + await invalidate_tag_cache("blog") + + # Redirect to the page admin + return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_page["slug"]))) + + @blogs_bp.get("/drafts/") async def drafts(): return redirect(host_url(url_for("blog.home")) + "?drafts=1") diff --git a/bp/blog/services/pages_data.py b/bp/blog/services/pages_data.py new file mode 100644 index 0000000..cc88fa1 --- /dev/null +++ b/bp/blog/services/pages_data.py @@ -0,0 +1,18 @@ +from ..ghost_db import DBClient + + +async def pages_data(session, page, search): + client = DBClient(session) + + pages, pagination = await client.list_pages( + limit=10, + page=page, + search=search, + ) + + return { + "pages": pages, + "page": pagination.get("page", page), + "total_pages": pagination.get("pages", 1), + "search": search, + } diff --git a/bp/post/admin/routes.py b/bp/post/admin/routes.py index bebd5f9..9bb396b 100644 --- a/bp/post/admin/routes.py +++ b/bp/post/admin/routes.py @@ -22,17 +22,91 @@ def register(): @require_admin async def admin(slug: str): from suma_browser.app.utils.htmx import is_htmx_request + from models.page_config import PageConfig + from sqlalchemy import select as sa_select + + # Load features for page admin + post = (g.post_data or {}).get("post", {}) + features = {} + if post.get("is_page"): + pc = (await g.s.execute( + sa_select(PageConfig).where(PageConfig.post_id == post["id"]) + )).scalar_one_or_none() + if pc: + features = pc.features or {} + + ctx = {"features": features} # Determine which template to use based on request type if not is_htmx_request(): # Normal browser request: full page with layout - html = await render_template("_types/post/admin/index.html") + html = await render_template("_types/post/admin/index.html", **ctx) else: # HTMX request: main panel + OOB elements - html = await render_template("_types/post/admin/_oob_elements.html") + html = await render_template("_types/post/admin/_oob_elements.html", **ctx) return await make_response(html) + @bp.put("/features/") + @require_admin + async def update_features(slug: str): + """Update PageConfig.features for a page.""" + from models.page_config import PageConfig + from models.ghost_content import Post + from sqlalchemy import select as sa_select + from quart import jsonify + import json + + post = g.post_data.get("post") + if not post or not post.get("is_page"): + return jsonify({"error": "This is not a page."}), 400 + + post_id = post["id"] + + # Load PageConfig + pc = (await g.s.execute( + sa_select(PageConfig).where(PageConfig.post_id == post_id) + )).scalar_one_or_none() + if pc is None: + return jsonify({"error": "PageConfig not found for this page."}), 404 + + # Parse request body + body = await request.get_json() + if body is None: + # Fall back to form data + form = await request.form + body = {} + for key in ("calendar", "market"): + val = form.get(key) + if val is not None: + body[key] = val in ("true", "1", "on") + + if not isinstance(body, dict): + return jsonify({"error": "Expected JSON object with feature flags."}), 400 + + # Merge features + features = dict(pc.features or {}) + for key, val in body.items(): + if isinstance(val, bool): + features[key] = val + elif val in ("true", "1", "on"): + features[key] = True + elif val in ("false", "0", "off", None): + features[key] = False + + pc.features = features + from sqlalchemy.orm.attributes import flag_modified + flag_modified(pc, "features") + await g.s.flush() + + # Return updated features panel + html = await render_template( + "_types/post/admin/_features_panel.html", + features=features, + post=post, + ) + return await make_response(html) + @bp.get("/data/") @require_admin async def data(slug: str): diff --git a/shared_lib b/shared_lib index fa9ffa9..56e3258 160000 --- a/shared_lib +++ b/shared_lib @@ -1 +1 @@ -Subproject commit fa9ffa98e500d8434fef5a8cea95b6818a875b59 +Subproject commit 56e32585b7c111ca0c417d44746840beae7af0e6 diff --git a/templates/_types/blog/_action_buttons.html b/templates/_types/blog/_action_buttons.html index 0ea7fa2..7184ab0 100644 --- a/templates/_types/blog/_action_buttons.html +++ b/templates/_types/blog/_action_buttons.html @@ -1,4 +1,4 @@ -{# New Post + Drafts toggle — shown in aside (desktop + mobile) #} +{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
+ Published: {{ page.published_at.strftime("%-d %b %Y at %H:%M") }} +
+ {% endif %} ++ {{ page.custom_excerpt or page.excerpt }} +
+ {% endif %} + +Payment connection coming soon.
+