feat: implement Pages as Spaces Phase 1
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
- Add PageConfig model with feature flags (calendar, market) - Auto-create PageConfig on Ghost page sync - Add create_page() for Ghost /pages/ API endpoint - Add /new-page/ route for creating pages - Add ?type=pages blog filter with Posts|Pages tab toggle - Add list_pages() to DBClient with PageConfig eager loading - Add PUT /<slug>/admin/features/ route for feature toggles - Add feature badges (calendar, market) on page cards - Add features panel to page admin dashboard - Update shared_lib submodule with PageConfig model Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,35 @@ async def create_post(
|
|||||||
return resp.json()["posts"][0]
|
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(
|
async def update_post(
|
||||||
ghost_id: str,
|
ghost_id: str,
|
||||||
lexical_json: str,
|
lexical_json: str,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm.attributes import flag_modified # for non-Mutable JSON colu
|
|||||||
from models.ghost_content import (
|
from models.ghost_content import (
|
||||||
Post, Author, Tag, PostAuthor, PostTag
|
Post, Author, Tag, PostAuthor, PostTag
|
||||||
)
|
)
|
||||||
|
from models.page_config import PageConfig
|
||||||
|
|
||||||
# User-centric membership models
|
# User-centric membership models
|
||||||
from models import User
|
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"]]
|
tt = tag_map[t["id"]]
|
||||||
sess.add(PostTag(post_id=obj.id, tag_id=tt.id, sort_order=idx))
|
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
|
return obj
|
||||||
|
|
||||||
async def _ghost_find_member_by_email(email: str) -> Optional[dict]:
|
async def _ghost_find_member_by_email(email: str) -> Optional[dict]:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload, joinedload
|
from sqlalchemy.orm import selectinload, joinedload
|
||||||
|
|
||||||
from models.ghost_content import Post, Author, Tag, PostTag
|
from models.ghost_content import Post, Author, Tag, PostTag
|
||||||
|
from models.page_config import PageConfig
|
||||||
from models.tag_group import TagGroup, TagGroupTag
|
from models.tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
|
||||||
@@ -235,6 +236,78 @@ class DBClient:
|
|||||||
|
|
||||||
return posts, pagination
|
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(
|
async def posts_by_slug(
|
||||||
self,
|
self,
|
||||||
slug: str,
|
slug: str,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .ghost_db import DBClient # adjust import path
|
|||||||
from db.session import get_session
|
from db.session import get_session
|
||||||
from .filters.qs import makeqs_factory, decode
|
from .filters.qs import makeqs_factory, decode
|
||||||
from .services.posts_data import posts_data
|
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.redis_cacher import cache_page, invalidate_tag_cache
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from suma_browser.app.utils.htmx import is_htmx_request
|
||||||
@@ -81,7 +82,24 @@ def register(url_prefix, title):
|
|||||||
async def home():
|
async def home():
|
||||||
|
|
||||||
q = decode()
|
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
|
# Drafts filter requires login; ignore if not logged in
|
||||||
show_drafts = bool(q.drafts and g.user)
|
show_drafts = bool(q.drafts and g.user)
|
||||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||||
@@ -99,6 +117,7 @@ def register(url_prefix, title):
|
|||||||
|
|
||||||
context = {
|
context = {
|
||||||
**data,
|
**data,
|
||||||
|
"content_type": "posts",
|
||||||
"selected_tags": q.selected_tags,
|
"selected_tags": q.selected_tags,
|
||||||
"selected_authors": q.selected_authors,
|
"selected_authors": q.selected_authors,
|
||||||
"selected_groups": q.selected_groups,
|
"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"])))
|
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/")
|
@blogs_bp.get("/drafts/")
|
||||||
async def drafts():
|
async def drafts():
|
||||||
return redirect(host_url(url_for("blog.home")) + "?drafts=1")
|
return redirect(host_url(url_for("blog.home")) + "?drafts=1")
|
||||||
|
|||||||
18
bp/blog/services/pages_data.py
Normal file
18
bp/blog/services/pages_data.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -22,17 +22,91 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def admin(slug: str):
|
async def admin(slug: str):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
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
|
# Determine which template to use based on request type
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
# 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:
|
else:
|
||||||
# HTMX request: main panel + OOB elements
|
# 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)
|
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/")
|
@bp.get("/data/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def data(slug: str):
|
async def data(slug: str):
|
||||||
|
|||||||
Submodule shared_lib updated: fa9ffa98e5...56e32585b7
@@ -1,4 +1,4 @@
|
|||||||
{# New Post + Drafts toggle — shown in aside (desktop + mobile) #}
|
{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
|
||||||
<div class="flex flex-wrap gap-2 px-4 py-3">
|
<div class="flex flex-wrap gap-2 px-4 py-3">
|
||||||
{% if has_access('blog.new_post') %}
|
{% if has_access('blog.new_post') %}
|
||||||
{% set new_href = url_for('blog.new_post')|host %}
|
{% set new_href = url_for('blog.new_post')|host %}
|
||||||
@@ -14,6 +14,19 @@
|
|||||||
>
|
>
|
||||||
<i class="fa fa-plus mr-1"></i> New Post
|
<i class="fa fa-plus mr-1"></i> New Post
|
||||||
</a>
|
</a>
|
||||||
|
{% set new_page_href = url_for('blog.new_page')|host %}
|
||||||
|
<a
|
||||||
|
href="{{ new_page_href }}"
|
||||||
|
hx-get="{{ new_page_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||||
|
title="New Page"
|
||||||
|
>
|
||||||
|
<i class="fa fa-plus mr-1"></i> New Page
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if g.user and (draft_count or drafts) %}
|
{% if g.user and (draft_count or drafts) %}
|
||||||
{% if drafts %}
|
{% if drafts %}
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
|
|
||||||
|
{# Content type tabs: Posts | Pages #}
|
||||||
|
<div class="flex justify-center gap-1 px-3 pt-3">
|
||||||
|
{% set posts_href = (url_for('blog.home'))|host %}
|
||||||
|
{% set pages_href = (url_for('blog.home') ~ '?type=pages')|host %}
|
||||||
|
<a
|
||||||
|
href="{{ posts_href }}"
|
||||||
|
hx-get="{{ posts_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{ hx_select_search }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
|
||||||
|
{{ 'bg-stone-700 text-white' if content_type != 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
|
||||||
|
>Posts</a>
|
||||||
|
<a
|
||||||
|
href="{{ pages_href }}"
|
||||||
|
hx-get="{{ pages_href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{ hx_select_search }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
|
||||||
|
{{ 'bg-stone-700 text-white' if content_type == 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
|
||||||
|
>Pages</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if content_type == 'pages' %}
|
||||||
|
{# Pages listing #}
|
||||||
|
<div class="max-w-full px-3 py-3 space-y-3">
|
||||||
|
{% set page_num = page %}
|
||||||
|
{% include "_types/blog/_page_cards.html" %}
|
||||||
|
</div>
|
||||||
|
<div class="pb-8"></div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
{# View toggle bar - desktop only #}
|
{# View toggle bar - desktop only #}
|
||||||
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
|
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
|
||||||
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
|
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
|
||||||
@@ -46,3 +81,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="pb-8"></div>
|
<div class="pb-8"></div>
|
||||||
|
{% endif %}{# end content_type check #}
|
||||||
|
|||||||
56
templates/_types/blog/_page_card.html
Normal file
56
templates/_types/blog/_page_card.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{# Single page card for pages listing #}
|
||||||
|
<article class="border-b pb-6 last:border-b-0 relative">
|
||||||
|
{% set _href = url_for('blog.post.post_detail', slug=page.slug)|host %}
|
||||||
|
<a
|
||||||
|
href="{{ _href }}"
|
||||||
|
hx-get="{{ _href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{ hx_select_search }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
||||||
|
>
|
||||||
|
<header class="mb-2 text-center">
|
||||||
|
<h2 class="text-4xl font-bold text-stone-900">
|
||||||
|
{{ page.title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{# Feature badges #}
|
||||||
|
{% if page.features %}
|
||||||
|
<div class="flex justify-center gap-2 mt-2">
|
||||||
|
{% if page.features.get('calendar') %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
|
||||||
|
<i class="fa fa-calendar mr-1"></i>Calendar
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.features.get('market') %}
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">
|
||||||
|
<i class="fa fa-shopping-bag mr-1"></i>Market
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page.published_at %}
|
||||||
|
<p class="text-sm text-stone-500">
|
||||||
|
Published: {{ page.published_at.strftime("%-d %b %Y at %H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if page.feature_image %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<img
|
||||||
|
src="{{ page.feature_image }}"
|
||||||
|
alt=""
|
||||||
|
class="rounded-lg w-full object-cover"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.custom_excerpt or page.excerpt %}
|
||||||
|
<p class="text-stone-700 text-lg leading-relaxed text-center">
|
||||||
|
{{ page.custom_excerpt or page.excerpt }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
19
templates/_types/blog/_page_cards.html
Normal file
19
templates/_types/blog/_page_cards.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{# Page cards loop with pagination sentinel #}
|
||||||
|
{% for page in pages %}
|
||||||
|
{% include "_types/blog/_page_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if page_num < total_pages|int %}
|
||||||
|
<div
|
||||||
|
id="sentinel-{{ page_num }}-d"
|
||||||
|
class="h-4 opacity-0 pointer-events-none"
|
||||||
|
hx-get="{{ (current_local_href ~ {'page': page_num + 1}|qs)|host }}"
|
||||||
|
hx-trigger="intersect once delay:250ms"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
></div>
|
||||||
|
{% else %}
|
||||||
|
{% if pages %}
|
||||||
|
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-span-full mt-8 text-center text-stone-500">No pages found.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
49
templates/_types/post/admin/_features_panel.html
Normal file
49
templates/_types/post/admin/_features_panel.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{# Feature toggles for PageConfig #}
|
||||||
|
<div id="features-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">
|
||||||
|
<h3 class="text-lg font-semibold text-stone-800">Page Features</h3>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-put="{{ url_for('blog.post.admin.update_features', slug=post.slug)|host }}"
|
||||||
|
hx-target="#features-panel"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
|
hx-ext="json-enc"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="calendar"
|
||||||
|
value="true"
|
||||||
|
{{ 'checked' if features.get('calendar') }}
|
||||||
|
class="h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
_="on change trigger submit on closest <form/>"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-stone-700">
|
||||||
|
<i class="fa fa-calendar text-blue-600 mr-1"></i>
|
||||||
|
Calendar — enable event booking on this page
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="market"
|
||||||
|
value="true"
|
||||||
|
{{ 'checked' if features.get('market') }}
|
||||||
|
class="h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"
|
||||||
|
_="on change trigger submit on closest <form/>"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-stone-700">
|
||||||
|
<i class="fa fa-shopping-bag text-green-600 mr-1"></i>
|
||||||
|
Market — enable product catalog on this page
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Phase 3: SumUp connection placeholder #}
|
||||||
|
<div class="mt-4 pt-4 border-t border-stone-100">
|
||||||
|
<h4 class="text-sm font-medium text-stone-500">SumUp Payment</h4>
|
||||||
|
<p class="text-xs text-stone-400 mt-1">Payment connection coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,5 +3,10 @@
|
|||||||
id="main-panel"
|
id="main-panel"
|
||||||
class="flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
class="flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||||
>
|
>
|
||||||
|
{% if post and post.is_page %}
|
||||||
|
<div class="max-w-lg mx-auto mt-6 px-4">
|
||||||
|
{% include "_types/post/admin/_features_panel.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="pb-8"></div>
|
<div class="pb-8"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user