Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s

Combines shared, blog, market, cart, events, federation, and account
into a single repository. Eliminates submodule sync, sibling model
copying at build time, and per-app CI orchestration.

Changes:
- Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs
- Remove stale sibling model copies from each app
- Update all 6 Dockerfiles for monorepo build context (root = .)
- Add build directives to docker-compose.yml
- Add single .gitea/workflows/ci.yml with change detection
- Add .dockerignore for monorepo build context
- Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

9
blog/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.env
node_modules/
*.egg-info/
dist/
build/
.venv/

61
blog/Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# syntax=docker/dockerfile:1
# ---------- Stage 1: Build editor JS/CSS ----------
FROM node:20-slim AS editor-build
WORKDIR /build
COPY shared/editor/package.json shared/editor/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY shared/editor/ ./
RUN NODE_ENV=production node build.mjs
# ---------- Stage 2: Python runtime ----------
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
WORKDIR /app
# Install system deps + psql client
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
# Shared code (replaces submodule)
COPY shared/ ./shared/
# App code
COPY blog/ ./
# Sibling models for cross-domain SQLAlchemy imports
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY cart/__init__.py ./cart/__init__.py
COPY cart/models/ ./cart/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/
# Copy built editor assets from stage 1
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/
# ---------- Runtime setup ----------
COPY blog/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE ${APP_PORT}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

60
blog/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Blog App (Coop)
Blog, authentication, and content management service for the Rose Ash cooperative platform. Handles Ghost CMS integration, user auth, and admin settings.
## Architecture
One of five Quart microservices sharing a single PostgreSQL database:
| App | Port | Domain |
|-----|------|--------|
| **blog (coop)** | 8000 | Auth, blog, admin, menus, snippets |
| market | 8001 | Product browsing, Suma scraping |
| cart | 8002 | Shopping cart, checkout, orders |
| events | 8003 | Calendars, bookings, tickets |
| federation | 8004 | ActivityPub, fediverse social |
## Structure
```
app.py # Application factory (create_base_app + blueprints)
path_setup.py # Adds project root + app dir to sys.path
config/app-config.yaml # App URLs, feature flags, SumUp config
models/ # Blog-domain models (+ re-export stubs for shared models)
bp/ # Blueprints
auth/ # Magic link login, account, newsletters
blog/ # Post listing, Ghost CMS sync
post/ # Single post view and admin
admin/ # Settings admin interface
menu_items/ # Navigation menu management
snippets/ # Reusable content snippets
templates/ # Jinja2 templates
services/ # register_domain_services() — wires blog + calendar + market + cart
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
```
## Cross-Domain Communication
All inter-app communication uses typed service contracts (no HTTP APIs):
- `services.calendar.*` — calendar/entry queries via CalendarService protocol
- `services.market.*` — marketplace queries via MarketService protocol
- `services.cart.*` — cart summary via CartService protocol
- `services.federation.*` — AP publishing via FederationService protocol
- `shared.services.navigation` — site navigation tree
## Domain Events
- `auth/routes.py` emits `user.logged_in` via `shared.events.emit_event`
- Ghost sync emits `post.published` / `post.updated` for federation
## Running
```bash
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
alembic -c shared/alembic.ini upgrade head
hypercorn app:app --bind 0.0.0.0:8000
```

0
blog/__init__.py Normal file
View File

138
blog/app.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from shared.config import config
from shared.models import KV
from bp import (
register_blog_bp,
register_admin,
register_menu_items,
register_snippets,
register_fragments,
)
async def blog_context() -> dict:
"""
Blog app context processor.
- cart_count/cart_total: via cart service (shared DB)
- cart_mini_html / auth_menu_html / nav_tree_html: pre-fetched fragments
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data via service (replaces cross-app HTTP API)
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
# Pre-fetch cross-app HTML fragments concurrently
# (fetch_fragment auto-skips when inside a fragment request to prevent circular deps)
user = getattr(g, "user", None)
cart_params = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
auth_params = {"email": user.email} if user else {}
nav_params = {"app_name": "blog", "path": request.path}
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", auth_params or None),
("blog", "nav-tree", nav_params),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
return ctx
def create_app() -> "Quart":
from services import register_domain_services
app = create_base_app(
"blog",
context_fn=blog_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
# --- blueprints ---
app.register_blueprint(
register_blog_bp(
url_prefix=config()["blog_root"],
title=config()["blog_title"],
),
url_prefix=config()["blog_root"],
)
app.register_blueprint(register_admin("/settings"))
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
# --- KV admin endpoints ---
@app.get("/settings/kv/<key>")
async def kv_get(key: str):
row = (
await g.s.execute(select(KV).where(KV.key == key))
).scalar_one_or_none()
return {"key": key, "value": (row.value if row else None)}
@app.post("/settings/kv/<key>")
async def kv_set(key: str):
data = await request.get_json() or {}
val = data.get("value", "")
obj = await g.s.get(KV, key)
if obj is None:
obj = KV(key=key, value=val)
g.s.add(obj)
else:
obj.value = val
return {"ok": True, "key": key, "value": val}
# --- debug: url rules ---
@app.get("/__rules")
async def dump_rules():
rules = []
for r in app.url_map.iter_rules():
rules.append({
"endpoint": r.endpoint,
"rule": repr(r.rule),
"methods": sorted(r.methods - {"HEAD", "OPTIONS"}),
"strict_slashes": r.strict_slashes,
})
return {"rules": rules}
return app
app = create_app()

5
blog/bp/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .blog.routes import register as register_blog_bp
from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .fragments import register_fragments

67
blog/bp/admin/routes.py Normal file
View File

@@ -0,0 +1,67 @@
from __future__ import annotations
#from quart import Blueprint, g
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
jsonify
)
from shared.browser.app.redis_cacher import clear_all_cache
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.config import config
from datetime import datetime
def register(url_prefix):
bp = Blueprint("settings", __name__, url_prefix = url_prefix)
@bp.context_processor
async def inject_root():
return {
"base_title": f"{config()['title']} settings",
}
@bp.get("/")
@require_admin
async def home():
# Determine which template to use based on request type and pagination
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/root/settings/index.html",
)
else:
html = await render_template("_types/root/settings/_oob_elements.html")
return await make_response(html)
@bp.get("/cache/")
@require_admin
async def cache():
if not is_htmx_request():
html = await render_template("_types/root/settings/cache/index.html")
else:
html = await render_template("_types/root/settings/cache/_oob_elements.html")
return await make_response(html)
@bp.post("/cache_clear/")
@require_admin
async def cache_clear():
await clear_all_cache()
if is_htmx_request():
now = datetime.now()
html = f'<span class="text-green-600 font-bold">Cache cleared at {now.strftime("%H:%M:%S")}</span>'
return html
return redirect(url_for("settings.cache"))
return bp

7
blog/bp/blog/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
# create the blueprint at package import time
from .routes import register # = Blueprint("browse_bp", __name__)
# import routes AFTER browse_bp is defined so routes can attach to it
from . import routes # noqa: F401

View File

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import re
from quart import (
render_template,
make_response,
Blueprint,
redirect,
url_for,
request,
g,
)
from sqlalchemy import select, delete
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import invalidate_tag_cache
from models.tag_group import TagGroup, TagGroupTag
from models.ghost_content import Tag
def _slugify(name: str) -> str:
s = name.strip().lower()
s = re.sub(r"[^\w\s-]", "", s)
s = re.sub(r"[\s_]+", "-", s)
return s.strip("-")
async def _unassigned_tags(session):
"""Return public, non-deleted tags not assigned to any group."""
assigned_sq = select(TagGroupTag.tag_id).subquery()
q = (
select(Tag)
.where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
Tag.id.notin_(select(assigned_sq)),
)
.order_by(Tag.name)
)
return list((await session.execute(q)).scalars())
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)
ctx = {"groups": groups, "unassigned_tags": unassigned}
if not is_htmx_request():
return await render_template("_types/blog/admin/tag_groups/index.html", **ctx)
else:
return await render_template("_types/blog/admin/tag_groups/_oob_elements.html", **ctx)
@bp.post("/")
@require_admin
async def create():
form = await request.form
name = (form.get("name") or "").strip()
if not name:
return redirect(url_for("blog.tag_groups_admin.index"))
slug = _slugify(name)
feature_image = (form.get("feature_image") or "").strip() or None
colour = (form.get("colour") or "").strip() or None
sort_order = int(form.get("sort_order") or 0)
tg = TagGroup(
name=name, slug=slug,
feature_image=feature_image, colour=colour,
sort_order=sort_order,
)
g.s.add(tg)
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,
}
if not is_htmx_request():
return await render_template("_types/blog/admin/tag_groups/edit.html", **ctx)
else:
return await render_template("_types/blog/admin/tag_groups/_edit_oob.html", **ctx)
@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"))
form = await request.form
name = (form.get("name") or "").strip()
if name:
tg.name = name
tg.slug = _slugify(name)
tg.feature_image = (form.get("feature_image") or "").strip() or None
tg.colour = (form.get("colour") or "").strip() or None
tg.sort_order = int(form.get("sort_order") or 0)
# Update tag assignments
selected_tag_ids = set()
for val in form.getlist("tag_ids"):
try:
selected_tag_ids.add(int(val))
except (ValueError, TypeError):
pass
# Remove old assignments
await g.s.execute(
delete(TagGroupTag).where(TagGroupTag.tag_group_id == id)
)
await g.s.flush()
# Add new assignments
for tid in selected_tag_ids:
g.s.add(TagGroupTag(tag_group_id=id, tag_id=tid))
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
@bp.post("/<int:id>/delete/")
@require_admin
async def delete_group(id: int):
tg = await g.s.get(TagGroup, id)
if tg:
await g.s.delete(tg)
await g.s.flush()
await invalidate_tag_cache("blog")
return redirect(url_for("blog.tag_groups_admin.index"))
return bp

120
blog/bp/blog/filters/qs.py Normal file
View File

@@ -0,0 +1,120 @@
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import (
KEEP, _norm, make_filter_set, build_qs,
)
from shared.browser.app.filters.query_types import BlogQuery
def decode() -> BlogQuery:
page = int(request.args.get("page", 1))
search = request.args.get("search")
sort = request.args.get("sort")
liked = request.args.get("liked")
drafts = request.args.get("drafts")
selected_tags = tuple(s.strip() for s in request.args.getlist("tag") if s.strip())[:1]
selected_authors = tuple(s.strip().lower() for s in request.args.getlist("author") if s.strip())[:1]
selected_groups = tuple(s.strip() for s in request.args.getlist("group") if s.strip())[:1]
view = request.args.get("view") or None
return BlogQuery(page, search, sort, selected_tags, selected_authors, liked, view, drafts, selected_groups)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Auto-resets page to 1 when filters change unless you pass page explicitly.
"""
q = decode()
base_tags = [s for s in q.selected_tags if (s or "").strip()]
base_authors = [s for s in q.selected_authors if (s or "").strip()]
base_groups = [s for s in q.selected_groups if (s or "").strip()]
base_search = q.search or None
base_liked = q.liked or None
base_sort = q.sort or None
base_page = int(q.page or 1)
base_view = q.view or None
base_drafts = q.drafts or None
def makeqs(
*,
clear_filters: bool = False,
add_tag: Union[str, Iterable[str], None] = None,
remove_tag: Union[str, Iterable[str], None] = None,
add_author: Union[str, Iterable[str], None] = None,
remove_author: Union[str, Iterable[str], None] = None,
add_group: Union[str, Iterable[str], None] = None,
remove_group: Union[str, Iterable[str], None] = None,
search: Union[str, None, object] = KEEP,
sort: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
liked: Union[bool, None, object] = KEEP,
view: Union[str, None, object] = KEEP,
drafts: Union[str, None, object] = KEEP,
) -> str:
groups = make_filter_set(base_groups, add_group, remove_group, clear_filters, single_select=True)
tags = make_filter_set(base_tags, add_tag, remove_tag, clear_filters, single_select=True)
authors = make_filter_set(base_authors, add_author, remove_author, clear_filters, single_select=True)
# Mutual exclusion: selecting a group clears tags, selecting a tag clears groups
if add_group is not None:
tags = []
if add_tag is not None:
groups = []
final_search = None if clear_filters else base_search if search is KEEP else ((search or "").strip() or None)
final_sort = base_sort if sort is KEEP else (sort or None)
final_liked = None if clear_filters else base_liked if liked is KEEP else liked
final_view = base_view if view is KEEP else (view or None)
final_drafts = None if clear_filters else base_drafts if drafts is KEEP else (drafts or None)
# Did filters change?
filters_changed = (
set(map(_norm, tags)) != set(map(_norm, base_tags))
or set(map(_norm, authors)) != set(map(_norm, base_authors))
or set(map(_norm, groups)) != set(map(_norm, base_groups))
or final_search != base_search
or final_sort != base_sort
or final_liked != base_liked
or final_drafts != base_drafts
)
# Page logic
if page is KEEP:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# Build params
params = []
for s in groups:
params.append(("group", s))
for s in tags:
params.append(("tag", s))
for s in authors:
params.append(("author", s))
if final_search:
params.append(("search", final_search))
if final_liked is not None:
params.append(("liked", final_liked))
if final_sort:
params.append(("sort", final_sort))
if final_view:
params.append(("view", final_view))
if final_drafts:
params.append(("drafts", final_drafts))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

View File

@@ -0,0 +1,256 @@
"""
Editor API proxy image/media/file uploads and oembed.
Forwards requests to the Ghost Admin API with JWT auth so the browser
never needs direct Ghost access.
"""
from __future__ import annotations
import logging
import os
import httpx
from quart import Blueprint, request, jsonify, g
from sqlalchemy import select, or_
from shared.browser.app.authz import require_admin, require_login
from models import Snippet
from .ghost_admin_token import make_ghost_admin_jwt
log = logging.getLogger(__name__)
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
MAX_MEDIA_SIZE = 100 * 1024 * 1024 # 100 MB
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
ALLOWED_IMAGE_MIMETYPES = frozenset({
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
})
ALLOWED_MEDIA_MIMETYPES = frozenset({
"audio/mpeg", "audio/ogg", "audio/wav", "audio/mp4", "audio/aac",
"video/mp4", "video/webm", "video/ogg",
})
editor_api_bp = Blueprint("editor_api", __name__, url_prefix="/editor-api")
def _auth_header() -> dict[str, str]:
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
@editor_api_bp.post("/images/upload/")
@require_admin
async def upload_image():
"""Proxy image upload to Ghost Admin API."""
files = await request.files
uploaded = files.get("file")
if not uploaded:
return jsonify({"errors": [{"message": "No file provided"}]}), 400
content = uploaded.read()
if len(content) > MAX_IMAGE_SIZE:
return jsonify({"errors": [{"message": "File too large (max 10 MB)"}]}), 413
if uploaded.content_type not in ALLOWED_IMAGE_MIMETYPES:
return jsonify({"errors": [{"message": f"Unsupported file type: {uploaded.content_type}"}]}), 415
url = f"{GHOST_ADMIN_API_URL}/images/upload/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
url,
headers=_auth_header(),
files={"file": (uploaded.filename, content, uploaded.content_type)},
)
if not resp.is_success:
log.error("Ghost image upload failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
@editor_api_bp.post("/media/upload/")
@require_admin
async def upload_media():
"""Proxy audio/video upload to Ghost Admin API."""
files = await request.files
uploaded = files.get("file")
if not uploaded:
return jsonify({"errors": [{"message": "No file provided"}]}), 400
content = uploaded.read()
if len(content) > MAX_MEDIA_SIZE:
return jsonify({"errors": [{"message": "File too large (max 100 MB)"}]}), 413
if uploaded.content_type not in ALLOWED_MEDIA_MIMETYPES:
return jsonify({"errors": [{"message": f"Unsupported media type: {uploaded.content_type}"}]}), 415
ghost_files = {"file": (uploaded.filename, content, uploaded.content_type)}
# Optional video thumbnail
thumbnail = files.get("thumbnail")
if thumbnail:
thumb_content = thumbnail.read()
ghost_files["thumbnail"] = (thumbnail.filename, thumb_content, thumbnail.content_type)
url = f"{GHOST_ADMIN_API_URL}/media/upload/"
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, headers=_auth_header(), files=ghost_files)
if not resp.is_success:
log.error("Ghost media upload failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
@editor_api_bp.post("/files/upload/")
@require_admin
async def upload_file():
"""Proxy file upload to Ghost Admin API."""
files = await request.files
uploaded = files.get("file")
if not uploaded:
return jsonify({"errors": [{"message": "No file provided"}]}), 400
content = uploaded.read()
if len(content) > MAX_FILE_SIZE:
return jsonify({"errors": [{"message": "File too large (max 50 MB)"}]}), 413
url = f"{GHOST_ADMIN_API_URL}/files/upload/"
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
url,
headers=_auth_header(),
files={"file": (uploaded.filename, content, uploaded.content_type)},
)
if not resp.is_success:
log.error("Ghost file upload failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
@editor_api_bp.get("/oembed/")
@require_admin
async def oembed_proxy():
"""Proxy oembed lookups to Ghost Admin API."""
params = dict(request.args)
if not params.get("url"):
return jsonify({"errors": [{"message": "url parameter required"}]}), 400
url = f"{GHOST_ADMIN_API_URL}/oembed/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url, headers=_auth_header(), params=params)
if not resp.is_success:
log.error("Ghost oembed failed %s: %s", resp.status_code, resp.text[:500])
return resp.json(), resp.status_code
# ── Snippets ────────────────────────────────────────────────────────
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
@editor_api_bp.get("/snippets/")
@require_login
async def list_snippets():
"""Return snippets visible to the current user."""
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()
return jsonify([
{"id": s.id, "name": s.name, "value": s.value, "visibility": s.visibility}
for s in rows
])
@editor_api_bp.post("/snippets/")
@require_login
async def create_snippet():
"""Create or upsert a snippet by (user_id, name)."""
data = await request.get_json(force=True)
name = (data.get("name") or "").strip()
value = data.get("value")
visibility = data.get("visibility", "private")
if not name or value is None:
return jsonify({"error": "name and value are required"}), 400
if visibility not in VALID_VISIBILITY:
return jsonify({"error": f"visibility must be one of {sorted(VALID_VISIBILITY)}"}), 400
if visibility != "private" and not g.rights.get("admin"):
visibility = "private"
uid = g.user.id
existing = (await g.s.execute(
select(Snippet).where(Snippet.user_id == uid, Snippet.name == name)
)).scalar_one_or_none()
if existing:
existing.value = value
existing.visibility = visibility
snippet = existing
else:
snippet = Snippet(user_id=uid, name=name, value=value, visibility=visibility)
g.s.add(snippet)
await g.s.flush()
return jsonify({
"id": snippet.id, "name": snippet.name,
"value": snippet.value, "visibility": snippet.visibility,
}), 200 if existing else 201
@editor_api_bp.patch("/snippets/<int:snippet_id>/")
@require_login
async def patch_snippet(snippet_id: int):
"""Update snippet visibility. Only admins may set shared/admin."""
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
return jsonify({"error": "not found"}), 404
is_admin = g.rights.get("admin")
if snippet.user_id != g.user.id and not is_admin:
return jsonify({"error": "forbidden"}), 403
data = await request.get_json(force=True)
visibility = data.get("visibility")
if visibility is not None:
if visibility not in VALID_VISIBILITY:
return jsonify({"error": f"visibility must be one of {sorted(VALID_VISIBILITY)}"}), 400
if visibility != "private" and not is_admin:
return jsonify({"error": "only admins may set shared/admin visibility"}), 403
snippet.visibility = visibility
await g.s.flush()
return jsonify({
"id": snippet.id, "name": snippet.name,
"value": snippet.value, "visibility": snippet.visibility,
})
@editor_api_bp.delete("/snippets/<int:snippet_id>/")
@require_login
async def delete_snippet(snippet_id: int):
"""Delete a snippet. Owners can delete their own; admins can delete any."""
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
return jsonify({"error": "not found"}), 404
if snippet.user_id != g.user.id and not g.rights.get("admin"):
return jsonify({"error": "forbidden"}), 403
await g.s.delete(snippet)
await g.s.flush()
return jsonify({"ok": True})

View File

@@ -0,0 +1,46 @@
import os
import time
import jwt # PyJWT
from typing import Tuple
def _split_key(raw_key: str) -> Tuple[str, bytes]:
"""
raw_key is the 'id:secret' from Ghost.
Returns (id, secret_bytes)
"""
key_id, key_secret_hex = raw_key.split(':', 1)
secret_bytes = bytes.fromhex(key_secret_hex)
return key_id, secret_bytes
def make_ghost_admin_jwt() -> str:
"""
Generate a short-lived JWT suitable for Authorization: Ghost <token>
"""
raw_key = os.environ["GHOST_ADMIN_API_KEY"]
key_id, secret_bytes = _split_key(raw_key)
now = int(time.time())
payload = {
"iat": now,
"exp": now + 5 * 60, # now + 5 minutes
"aud": "/admin/",
}
headers = {
"alg": "HS256",
"kid": key_id,
"typ": "JWT",
}
token = jwt.encode(
payload,
secret_bytes,
algorithm="HS256",
headers=headers,
)
# PyJWT returns str in recent versions; Ghost expects bare token string
return token

View File

@@ -0,0 +1,204 @@
"""
Ghost Admin API post CRUD.
Uses the same JWT auth and httpx patterns as ghost_sync.py.
"""
from __future__ import annotations
import logging
import os
import httpx
from .ghost_admin_token import make_ghost_admin_jwt
log = logging.getLogger(__name__)
GHOST_ADMIN_API_URL = os.environ["GHOST_ADMIN_API_URL"]
def _auth_header() -> dict[str, str]:
return {"Authorization": f"Ghost {make_ghost_admin_jwt()}"}
def _check(resp: httpx.Response) -> None:
"""Raise with the Ghost error body so callers see what went wrong."""
if resp.is_success:
return
body = resp.text[:2000]
log.error("Ghost API %s %s%s: %s", resp.request.method, resp.request.url, resp.status_code, body)
resp.raise_for_status()
async def get_post_for_edit(ghost_id: str, *, is_page: bool = False) -> dict | None:
"""Fetch a single post/page by Ghost ID, including lexical source."""
resource = "pages" if is_page else "posts"
url = (
f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
"?formats=lexical,html,mobiledoc&include=newsletters"
)
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url, headers=_auth_header())
if resp.status_code == 404:
return None
_check(resp)
return resp.json()[resource][0]
async def create_post(
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 post in Ghost. Returns the created post dict."""
post_body: dict = {
"title": title,
"lexical": lexical_json,
"mobiledoc": None,
"status": status,
}
if feature_image:
post_body["feature_image"] = feature_image
if custom_excerpt:
post_body["custom_excerpt"] = custom_excerpt
if feature_image_caption is not None:
post_body["feature_image_caption"] = feature_image_caption
payload = {"posts": [post_body]}
url = f"{GHOST_ADMIN_API_URL}/posts/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, json=payload, headers=_auth_header())
_check(resp)
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,
title: str | None,
updated_at: str,
feature_image: str | None = None,
custom_excerpt: str | None = None,
feature_image_caption: str | None = None,
status: str | None = None,
newsletter_slug: str | None = None,
email_segment: str | None = None,
email_only: bool | None = None,
is_page: bool = False,
) -> dict:
"""Update an existing Ghost post. Returns the updated post dict.
``updated_at`` is Ghost's optimistic-locking token pass the value
you received from ``get_post_for_edit``.
When ``newsletter_slug`` is set the publish request also triggers an
email send via Ghost's query-parameter API:
``?newsletter={slug}&email_segment={segment}``.
"""
post_body: dict = {
"lexical": lexical_json,
"mobiledoc": None,
"updated_at": updated_at,
}
if title is not None:
post_body["title"] = title
if feature_image is not None:
post_body["feature_image"] = feature_image or None
if custom_excerpt is not None:
post_body["custom_excerpt"] = custom_excerpt or None
if feature_image_caption is not None:
post_body["feature_image_caption"] = feature_image_caption
if status is not None:
post_body["status"] = status
if email_only:
post_body["email_only"] = True
resource = "pages" if is_page else "posts"
payload = {resource: [post_body]}
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
if newsletter_slug:
url += f"?newsletter={newsletter_slug}"
if email_segment:
url += f"&email_segment={email_segment}"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.put(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()[resource][0]
_SETTINGS_FIELDS = (
"slug",
"published_at",
"featured",
"visibility",
"email_only",
"custom_template",
"meta_title",
"meta_description",
"canonical_url",
"og_image",
"og_title",
"og_description",
"twitter_image",
"twitter_title",
"twitter_description",
"tags",
"feature_image_alt",
)
async def update_post_settings(
ghost_id: str,
updated_at: str,
is_page: bool = False,
**kwargs,
) -> dict:
"""Update Ghost post/page settings (slug, tags, SEO, social, etc.).
Only non-None keyword args are included in the PUT payload.
Accepts any key from ``_SETTINGS_FIELDS``.
"""
resource = "pages" if is_page else "posts"
post_body: dict = {"updated_at": updated_at}
for key in _SETTINGS_FIELDS:
val = kwargs.get(key)
if val is not None:
post_body[key] = val
payload = {resource: [post_body]}
url = f"{GHOST_ADMIN_API_URL}/{resource}/{ghost_id}/"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.put(url, json=payload, headers=_auth_header())
_check(resp)
return resp.json()[resource][0]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,668 @@
"""
Lexical JSON → HTML renderer.
Produces HTML matching Ghost's ``kg-*`` class conventions so the existing
``cards.css`` stylesheet works unchanged.
Public API
----------
render_lexical(doc) Lexical JSON (dict or string) → HTML string
"""
from __future__ import annotations
import html
import json
from typing import Callable
import mistune
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_RENDERERS: dict[str, Callable[[dict], str]] = {}
def _renderer(node_type: str):
"""Decorator — register a function as the renderer for *node_type*."""
def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
_RENDERERS[node_type] = fn
return fn
return decorator
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def render_lexical(doc: dict | str) -> str:
"""Render a Lexical JSON document to an HTML string."""
if isinstance(doc, str):
doc = json.loads(doc)
root = doc.get("root", doc)
return _render_children(root.get("children", []))
# ---------------------------------------------------------------------------
# Core dispatch
# ---------------------------------------------------------------------------
def _render_node(node: dict) -> str:
node_type = node.get("type", "")
renderer = _RENDERERS.get(node_type)
if renderer:
return renderer(node)
return ""
def _render_children(children: list[dict]) -> str:
return "".join(_render_node(c) for c in children)
# ---------------------------------------------------------------------------
# Text formatting
# ---------------------------------------------------------------------------
# Lexical format bitmask
_FORMAT_BOLD = 1
_FORMAT_ITALIC = 2
_FORMAT_STRIKETHROUGH = 4
_FORMAT_UNDERLINE = 8
_FORMAT_CODE = 16
_FORMAT_SUBSCRIPT = 32
_FORMAT_SUPERSCRIPT = 64
_FORMAT_HIGHLIGHT = 128
_FORMAT_TAGS: list[tuple[int, str, str]] = [
(_FORMAT_BOLD, "<strong>", "</strong>"),
(_FORMAT_ITALIC, "<em>", "</em>"),
(_FORMAT_STRIKETHROUGH, "<s>", "</s>"),
(_FORMAT_UNDERLINE, "<u>", "</u>"),
(_FORMAT_CODE, "<code>", "</code>"),
(_FORMAT_SUBSCRIPT, "<sub>", "</sub>"),
(_FORMAT_SUPERSCRIPT, "<sup>", "</sup>"),
(_FORMAT_HIGHLIGHT, "<mark>", "</mark>"),
]
# Element-level alignment from ``format`` field
_ALIGN_MAP = {
1: "text-align: left",
2: "text-align: center",
3: "text-align: right",
4: "text-align: justify",
}
def _align_style(node: dict) -> str:
fmt = node.get("format")
if isinstance(fmt, int) and fmt in _ALIGN_MAP:
return f' style="{_ALIGN_MAP[fmt]}"'
if isinstance(fmt, str) and fmt:
return f' style="text-align: {fmt}"'
return ""
def _wrap_format(text: str, fmt: int) -> str:
for mask, open_tag, close_tag in _FORMAT_TAGS:
if fmt & mask:
text = f"{open_tag}{text}{close_tag}"
return text
# ---------------------------------------------------------------------------
# Tier 1 — text nodes
# ---------------------------------------------------------------------------
@_renderer("text")
def _text(node: dict) -> str:
text = html.escape(node.get("text", ""))
fmt = node.get("format", 0)
if isinstance(fmt, int) and fmt:
text = _wrap_format(text, fmt)
return text
@_renderer("linebreak")
def _linebreak(_node: dict) -> str:
return "<br>"
@_renderer("tab")
def _tab(_node: dict) -> str:
return "\t"
@_renderer("paragraph")
def _paragraph(node: dict) -> str:
inner = _render_children(node.get("children", []))
if not inner:
inner = "<br>"
style = _align_style(node)
return f"<p{style}>{inner}</p>"
@_renderer("extended-text")
def _extended_text(node: dict) -> str:
return _paragraph(node)
@_renderer("heading")
def _heading(node: dict) -> str:
tag = node.get("tag", "h2")
inner = _render_children(node.get("children", []))
style = _align_style(node)
return f"<{tag}{style}>{inner}</{tag}>"
@_renderer("extended-heading")
def _extended_heading(node: dict) -> str:
return _heading(node)
@_renderer("quote")
def _quote(node: dict) -> str:
inner = _render_children(node.get("children", []))
return f"<blockquote>{inner}</blockquote>"
@_renderer("extended-quote")
def _extended_quote(node: dict) -> str:
return _quote(node)
@_renderer("aside")
def _aside(node: dict) -> str:
inner = _render_children(node.get("children", []))
return f"<aside>{inner}</aside>"
@_renderer("link")
def _link(node: dict) -> str:
href = html.escape(node.get("url", ""), quote=True)
target = node.get("target", "")
rel = node.get("rel", "")
inner = _render_children(node.get("children", []))
attrs = f' href="{href}"'
if target:
attrs += f' target="{html.escape(target, quote=True)}"'
if rel:
attrs += f' rel="{html.escape(rel, quote=True)}"'
return f"<a{attrs}>{inner}</a>"
@_renderer("autolink")
def _autolink(node: dict) -> str:
return _link(node)
@_renderer("at-link")
def _at_link(node: dict) -> str:
return _link(node)
@_renderer("list")
def _list(node: dict) -> str:
tag = "ol" if node.get("listType") == "number" else "ul"
start = node.get("start")
inner = _render_children(node.get("children", []))
attrs = ""
if tag == "ol" and start and start != 1:
attrs = f' start="{start}"'
return f"<{tag}{attrs}>{inner}</{tag}>"
@_renderer("listitem")
def _listitem(node: dict) -> str:
inner = _render_children(node.get("children", []))
return f"<li>{inner}</li>"
@_renderer("horizontalrule")
def _horizontalrule(_node: dict) -> str:
return "<hr>"
@_renderer("code")
def _code(node: dict) -> str:
# Inline code nodes from Lexical — just render inner text
inner = _render_children(node.get("children", []))
return f"<code>{inner}</code>"
@_renderer("codeblock")
def _codeblock(node: dict) -> str:
lang = node.get("language", "")
code = html.escape(node.get("code", ""))
cls = f' class="language-{html.escape(lang)}"' if lang else ""
return f'<pre><code{cls}>{code}</code></pre>'
@_renderer("code-highlight")
def _code_highlight(node: dict) -> str:
text = html.escape(node.get("text", ""))
highlight_type = node.get("highlightType", "")
if highlight_type:
return f'<span class="token {html.escape(highlight_type)}">{text}</span>'
return text
# ---------------------------------------------------------------------------
# Tier 2 — common cards
# ---------------------------------------------------------------------------
@_renderer("image")
def _image(node: dict) -> str:
src = node.get("src", "")
alt = node.get("alt", "")
caption = node.get("caption", "")
width = node.get("cardWidth", "") or node.get("width", "")
href = node.get("href", "")
width_class = ""
if width == "wide":
width_class = " kg-width-wide"
elif width == "full":
width_class = " kg-width-full"
img_tag = f'<img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy">'
if href:
img_tag = f'<a href="{html.escape(href, quote=True)}">{img_tag}</a>'
parts = [f'<figure class="kg-card kg-image-card{width_class}">']
parts.append(img_tag)
if caption:
parts.append(f"<figcaption>{caption}</figcaption>")
parts.append("</figure>")
return "".join(parts)
@_renderer("gallery")
def _gallery(node: dict) -> str:
images = node.get("images", [])
if not images:
return ""
rows = []
for i in range(0, len(images), 3):
row_imgs = images[i:i + 3]
row_cls = f"kg-gallery-row" if len(row_imgs) <= 3 else "kg-gallery-row"
imgs_html = []
for img in row_imgs:
src = img.get("src", "")
alt = img.get("alt", "")
caption = img.get("caption", "")
img_tag = f'<img src="{html.escape(src, quote=True)}" alt="{html.escape(alt, quote=True)}" loading="lazy">'
fig = f'<figure class="kg-gallery-image">{img_tag}'
if caption:
fig += f"<figcaption>{caption}</figcaption>"
fig += "</figure>"
imgs_html.append(fig)
rows.append(f'<div class="{row_cls}">{"".join(imgs_html)}</div>')
caption = node.get("caption", "")
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-gallery-card kg-width-wide">'
f'<div class="kg-gallery-container">{"".join(rows)}</div>'
f"{caption_html}</figure>"
)
@_renderer("html")
def _html_card(node: dict) -> str:
raw = node.get("html", "")
return f"<!--kg-card-begin: html-->{raw}<!--kg-card-end: html-->"
@_renderer("markdown")
def _markdown(node: dict) -> str:
md_text = node.get("markdown", "")
rendered = mistune.html(md_text)
return f"<!--kg-card-begin: markdown-->{rendered}<!--kg-card-end: markdown-->"
@_renderer("embed")
def _embed(node: dict) -> str:
embed_html = node.get("html", "")
caption = node.get("caption", "")
url = node.get("url", "")
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-embed-card">'
f"{embed_html}{caption_html}</figure>"
)
@_renderer("bookmark")
def _bookmark(node: dict) -> str:
url = node.get("url", "")
title = html.escape(node.get("metadata", {}).get("title", "") or node.get("title", ""))
description = html.escape(node.get("metadata", {}).get("description", "") or node.get("description", ""))
icon = node.get("metadata", {}).get("icon", "") or node.get("icon", "")
author = html.escape(node.get("metadata", {}).get("author", "") or node.get("author", ""))
publisher = html.escape(node.get("metadata", {}).get("publisher", "") or node.get("publisher", ""))
thumbnail = node.get("metadata", {}).get("thumbnail", "") or node.get("thumbnail", "")
caption = node.get("caption", "")
icon_html = f'<img class="kg-bookmark-icon" src="{html.escape(icon, quote=True)}" alt="">' if icon else ""
thumbnail_html = (
f'<div class="kg-bookmark-thumbnail">'
f'<img src="{html.escape(thumbnail, quote=True)}" alt=""></div>'
) if thumbnail else ""
meta_parts = []
if icon_html:
meta_parts.append(icon_html)
if author:
meta_parts.append(f'<span class="kg-bookmark-author">{author}</span>')
if publisher:
meta_parts.append(f'<span class="kg-bookmark-publisher">{publisher}</span>')
metadata_html = f'<span class="kg-bookmark-metadata">{"".join(meta_parts)}</span>' if meta_parts else ""
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-bookmark-card">'
f'<a class="kg-bookmark-container" href="{html.escape(url, quote=True)}">'
f'<div class="kg-bookmark-content">'
f'<div class="kg-bookmark-title">{title}</div>'
f'<div class="kg-bookmark-description">{description}</div>'
f'{metadata_html}'
f'</div>'
f'{thumbnail_html}'
f'</a>'
f'{caption_html}'
f'</figure>'
)
@_renderer("callout")
def _callout(node: dict) -> str:
color = node.get("backgroundColor", "grey")
emoji = node.get("calloutEmoji", "")
inner = _render_children(node.get("children", []))
emoji_html = f'<div class="kg-callout-emoji">{emoji}</div>' if emoji else ""
return (
f'<div class="kg-card kg-callout-card kg-callout-card-{html.escape(color)}">'
f'{emoji_html}'
f'<div class="kg-callout-text">{inner}</div>'
f'</div>'
)
@_renderer("button")
def _button(node: dict) -> str:
text = html.escape(node.get("buttonText", ""))
url = html.escape(node.get("buttonUrl", ""), quote=True)
alignment = node.get("alignment", "center")
return (
f'<div class="kg-card kg-button-card kg-align-{alignment}">'
f'<a href="{url}" class="kg-btn kg-btn-accent">{text}</a>'
f'</div>'
)
@_renderer("toggle")
def _toggle(node: dict) -> str:
heading = node.get("heading", "")
# Toggle content is in children
inner = _render_children(node.get("children", []))
return (
f'<div class="kg-card kg-toggle-card" data-kg-toggle-state="close">'
f'<div class="kg-toggle-heading">'
f'<h4 class="kg-toggle-heading-text">{heading}</h4>'
f'<button class="kg-toggle-card-icon">'
f'<svg viewBox="0 0 14 14"><path d="M7 0a.5.5 0 0 1 .5.5v6h6a.5.5 0 1 1 0 1h-6v6a.5.5 0 1 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 7 0Z" fill="currentColor"/></svg>'
f'</button>'
f'</div>'
f'<div class="kg-toggle-content">{inner}</div>'
f'</div>'
)
# ---------------------------------------------------------------------------
# Tier 3 — media & remaining cards
# ---------------------------------------------------------------------------
@_renderer("audio")
def _audio(node: dict) -> str:
src = node.get("src", "")
title = html.escape(node.get("title", ""))
duration = node.get("duration", 0)
thumbnail = node.get("thumbnailSrc", "")
duration_min = int(duration) // 60
duration_sec = int(duration) % 60
duration_str = f"{duration_min}:{duration_sec:02d}"
if thumbnail:
thumb_html = (
f'<img src="{html.escape(thumbnail, quote=True)}" alt="audio-thumbnail" '
f'class="kg-audio-thumbnail">'
)
else:
thumb_html = (
'<div class="kg-audio-thumbnail placeholder">'
'<svg viewBox="0 0 24 24"><path d="M2 12C2 6.48 6.48 2 12 2s10 4.48 10 10-4.48 10-10 10S2 17.52 2 12zm7.5 5.25L16 12 9.5 6.75v10.5z" fill="currentColor"/></svg>'
'</div>'
)
return (
f'<div class="kg-card kg-audio-card">'
f'{thumb_html}'
f'<div class="kg-audio-player-container">'
f'<div class="kg-audio-title">{title}</div>'
f'<div class="kg-audio-player">'
f'<button class="kg-audio-play-icon"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg></button>'
f'<div class="kg-audio-current-time">0:00</div>'
f'<div class="kg-audio-time">/ {duration_str}</div>'
f'<input type="range" class="kg-audio-seek-slider" max="100" value="0">'
f'<button class="kg-audio-playback-rate">1&#215;</button>'
f'<button class="kg-audio-unmute-icon"><svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" fill="currentColor"/></svg></button>'
f'<input type="range" class="kg-audio-volume-slider" max="100" value="100">'
f'</div>'
f'</div>'
f'<audio src="{html.escape(src, quote=True)}" preload="metadata"></audio>'
f'</div>'
)
@_renderer("video")
def _video(node: dict) -> str:
src = node.get("src", "")
caption = node.get("caption", "")
width = node.get("cardWidth", "")
thumbnail = node.get("thumbnailSrc", "") or node.get("customThumbnailSrc", "")
loop = node.get("loop", False)
width_class = ""
if width == "wide":
width_class = " kg-width-wide"
elif width == "full":
width_class = " kg-width-full"
loop_attr = " loop" if loop else ""
poster_attr = f' poster="{html.escape(thumbnail, quote=True)}"' if thumbnail else ""
caption_html = f"<figcaption>{caption}</figcaption>" if caption else ""
return (
f'<figure class="kg-card kg-video-card{width_class}">'
f'<div class="kg-video-container">'
f'<video src="{html.escape(src, quote=True)}" controls preload="metadata"{poster_attr}{loop_attr}></video>'
f'</div>'
f'{caption_html}'
f'</figure>'
)
@_renderer("file")
def _file(node: dict) -> str:
src = node.get("src", "")
title = html.escape(node.get("fileName", "") or node.get("title", ""))
caption = node.get("caption", "")
file_size = node.get("fileSize", 0)
file_name = html.escape(node.get("fileName", ""))
# Format size
if file_size:
kb = file_size / 1024
if kb < 1024:
size_str = f"{kb:.0f} KB"
else:
size_str = f"{kb / 1024:.1f} MB"
else:
size_str = ""
caption_html = f'<div class="kg-file-card-caption">{caption}</div>' if caption else ""
size_html = f'<div class="kg-file-card-filesize">{size_str}</div>' if size_str else ""
return (
f'<div class="kg-card kg-file-card">'
f'<a class="kg-file-card-container" href="{html.escape(src, quote=True)}" download="{file_name}">'
f'<div class="kg-file-card-contents">'
f'<div class="kg-file-card-title">{title}</div>'
f'{size_html}'
f'</div>'
f'<div class="kg-file-card-icon">'
f'<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor"/></svg>'
f'</div>'
f'</a>'
f'{caption_html}'
f'</div>'
)
@_renderer("paywall")
def _paywall(_node: dict) -> str:
return "<!--members-only-->"
@_renderer("header")
def _header(node: dict) -> str:
heading = node.get("heading", "")
subheading = node.get("subheading", "")
size = node.get("size", "small")
style = node.get("style", "dark")
bg_image = node.get("backgroundImageSrc", "")
button_text = node.get("buttonText", "")
button_url = node.get("buttonUrl", "")
bg_style = f' style="background-image: url({html.escape(bg_image, quote=True)})"' if bg_image else ""
heading_html = f"<h2>{heading}</h2>" if heading else ""
subheading_html = f"<p>{subheading}</p>" if subheading else ""
button_html = (
f'<a class="kg-header-card-button" href="{html.escape(button_url, quote=True)}">{html.escape(button_text)}</a>'
if button_text and button_url else ""
)
return (
f'<div class="kg-card kg-header-card kg-style-{html.escape(style)} kg-size-{html.escape(size)}"{bg_style}>'
f'{heading_html}{subheading_html}{button_html}'
f'</div>'
)
@_renderer("signup")
def _signup(node: dict) -> str:
heading = node.get("heading", "")
subheading = node.get("subheading", "")
disclaimer = node.get("disclaimer", "")
button_text = html.escape(node.get("buttonText", "Subscribe"))
button_color = node.get("buttonColor", "")
bg_color = node.get("backgroundColor", "")
bg_image = node.get("backgroundImageSrc", "")
style = node.get("style", "dark")
bg_style_parts = []
if bg_color:
bg_style_parts.append(f"background-color: {bg_color}")
if bg_image:
bg_style_parts.append(f"background-image: url({html.escape(bg_image, quote=True)})")
style_attr = f' style="{"; ".join(bg_style_parts)}"' if bg_style_parts else ""
heading_html = f"<h2>{heading}</h2>" if heading else ""
subheading_html = f"<p>{subheading}</p>" if subheading else ""
disclaimer_html = f'<p class="kg-signup-card-disclaimer">{disclaimer}</p>' if disclaimer else ""
btn_style = f' style="background-color: {button_color}"' if button_color else ""
return (
f'<div class="kg-card kg-signup-card kg-style-{html.escape(style)}"{style_attr}>'
f'{heading_html}{subheading_html}'
f'<form class="kg-signup-card-form" data-members-form>'
f'<input type="email" placeholder="Your email" required>'
f'<button type="submit" class="kg-signup-card-button"{btn_style}>{button_text}</button>'
f'</form>'
f'{disclaimer_html}'
f'</div>'
)
@_renderer("product")
def _product(node: dict) -> str:
title = html.escape(node.get("productTitle", "") or node.get("title", ""))
description = node.get("productDescription", "") or node.get("description", "")
img_src = node.get("productImageSrc", "")
button_text = html.escape(node.get("buttonText", ""))
button_url = node.get("buttonUrl", "")
rating = node.get("rating", 0)
img_html = (
f'<img class="kg-product-card-image" src="{html.escape(img_src, quote=True)}" alt="">'
if img_src else ""
)
button_html = (
f'<a class="kg-product-card-button kg-btn kg-btn-accent" href="{html.escape(button_url, quote=True)}">{button_text}</a>'
if button_text and button_url else ""
)
stars = ""
if rating:
active = int(rating)
stars_html = []
for i in range(5):
cls = "kg-product-card-rating-active" if i < active else ""
stars_html.append(
f'<svg class="kg-product-card-rating-star {cls}" viewBox="0 0 24 24">'
f'<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279L0 9.306l8.332-1.151z" fill="currentColor"/>'
f'</svg>'
)
stars = f'<div class="kg-product-card-rating">{"".join(stars_html)}</div>'
return (
f'<div class="kg-card kg-product-card">'
f'{img_html}'
f'<div class="kg-product-card-container">'
f'<h4 class="kg-product-card-title">{title}</h4>'
f'{stars}'
f'<div class="kg-product-card-description">{description}</div>'
f'{button_html}'
f'</div>'
f'</div>'
)
@_renderer("email")
def _email(node: dict) -> str:
raw_html = node.get("html", "")
return f"<!--kg-card-begin: email-->{raw_html}<!--kg-card-end: email-->"
@_renderer("email-cta")
def _email_cta(node: dict) -> str:
raw_html = node.get("html", "")
return f"<!--kg-card-begin: email-cta-->{raw_html}<!--kg-card-end: email-cta-->"
@_renderer("call-to-action")
def _call_to_action(node: dict) -> str:
raw_html = node.get("html", "")
sponsor_label = node.get("sponsorLabel", "")
label_html = (
f'<span class="kg-cta-sponsor-label">{html.escape(sponsor_label)}</span>'
if sponsor_label else ""
)
return (
f'<div class="kg-card kg-cta-card">'
f'{label_html}{raw_html}'
f'</div>'
)

View File

@@ -0,0 +1,86 @@
"""
Server-side validation for Lexical editor JSON.
Walk the document tree and reject any node whose ``type`` is not in
ALLOWED_NODE_TYPES. This is a belt-and-braces check: the Lexical
client already restricts which nodes can be created, but we validate
server-side too.
"""
from __future__ import annotations
ALLOWED_NODE_TYPES: frozenset[str] = frozenset(
{
# Standard Lexical nodes
"root",
"paragraph",
"heading",
"quote",
"list",
"listitem",
"link",
"autolink",
"code",
"code-highlight",
"linebreak",
"text",
"horizontalrule",
"image",
"tab",
# Ghost "extended-*" variants
"extended-text",
"extended-heading",
"extended-quote",
# Ghost card types
"html",
"gallery",
"embed",
"bookmark",
"markdown",
"email",
"email-cta",
"button",
"callout",
"toggle",
"video",
"audio",
"file",
"product",
"header",
"signup",
"aside",
"codeblock",
"call-to-action",
"at-link",
"paywall",
}
)
def validate_lexical(doc: dict) -> tuple[bool, str | None]:
"""Recursively validate a Lexical JSON document.
Returns ``(True, None)`` when the document is valid, or
``(False, reason)`` when an unknown node type is found.
"""
if not isinstance(doc, dict):
return False, "Document must be a JSON object"
root = doc.get("root")
if not isinstance(root, dict):
return False, "Document must contain a 'root' object"
return _walk(root)
def _walk(node: dict) -> tuple[bool, str | None]:
node_type = node.get("type")
if node_type is not None and node_type not in ALLOWED_NODE_TYPES:
return False, f"Disallowed node type: {node_type}"
for child in node.get("children", []):
if isinstance(child, dict):
ok, reason = _walk(child)
if not ok:
return False, reason
return True, None

632
blog/bp/blog/ghost_db.py Normal file
View File

@@ -0,0 +1,632 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import select, func, asc, desc, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
from models.ghost_content import Post, Author, Tag, PostTag
from shared.models.page_config import PageConfig
from models.tag_group import TagGroup, TagGroupTag
class DBAPIError(Exception):
"""Raised when our local DB returns something unexpected."""
def _author_to_public(a: Optional[Author]) -> Optional[Dict[str, Any]]:
if a is None:
return None
if a.deleted_at is not None:
# treat deleted authors as missing
return None
return {
"id": a.ghost_id,
"slug": a.slug,
"name": a.name,
"profile_image": a.profile_image,
"cover_image": a.cover_image,
# expose more (bio, etc.) if needed
}
def _tag_to_public(t: Tag) -> Dict[str, Any]:
return {
"id": t.ghost_id,
"slug": t.slug,
"name": t.name,
"description": t.description,
"feature_image": t.feature_image, # fixed key
"visibility": t.visibility,
"deleted_at": t.deleted_at,
}
def _post_to_public(p: Post) -> Dict[str, Any]:
"""
Shape a Post to the public JSON used by the app, mirroring GhostClient._normalise_post.
"""
# Primary author: explicit or first available
primary_author = p.primary_author or (p.authors[0] if p.authors else None)
# Primary tag: prefer explicit relationship, otherwise first public/non-deleted tag
primary_tag = getattr(p, "primary_tag", None)
if primary_tag is None:
public_tags = [
t for t in (p.tags or [])
if t.deleted_at is None and (t.visibility or "public") == "public"
]
primary_tag = public_tags[0] if public_tags else None
return {
"id": p.id,
"ghost_id": p.ghost_id,
"slug": p.slug,
"title": p.title,
"html": p.html,
"is_page": p.is_page,
"excerpt": p.custom_excerpt or p.excerpt,
"custom_excerpt": p.custom_excerpt,
"published_at": p.published_at,
"updated_at": p.updated_at,
"visibility": p.visibility,
"status": p.status,
"deleted_at": p.deleted_at,
"feature_image": p.feature_image,
"user_id": p.user_id,
"publish_requested": p.publish_requested,
"primary_author": _author_to_public(primary_author),
"primary_tag": _tag_to_public(primary_tag) if primary_tag else None,
"tags": [
_tag_to_public(t)
for t in (p.tags or [])
if t.deleted_at is None and (t.visibility or "public") == "public"
],
"authors": [
_author_to_public(a)
for a in (p.authors or [])
if a and a.deleted_at is None
],
}
class DBClient:
"""
Drop-in replacement for GhostClient, but served from our mirrored tables.
Call methods with an AsyncSession.
"""
def __init__(self, session: AsyncSession):
self.sess = session
async def list_posts(
self,
limit: int = 10,
page: int = 1,
selected_tags: Optional[Sequence[str]] = None,
selected_authors: Optional[Sequence[str]] = None,
search: Optional[str] = None,
drafts: bool = False,
drafts_user_id: Optional[int] = None,
exclude_covered_tag_ids: Optional[Sequence[int]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""
List published posts, optionally filtered by tags/authors and a search term.
When drafts=True, lists draft posts instead (filtered by drafts_user_id if given).
Returns (posts, pagination).
"""
# ---- base visibility filters
if drafts:
base_filters = [
Post.deleted_at.is_(None),
Post.status == "draft",
Post.is_page.is_(False),
]
if drafts_user_id is not None:
base_filters.append(Post.user_id == drafts_user_id)
else:
base_filters = [
Post.deleted_at.is_(None),
Post.status == "published",
Post.is_page.is_(False),
]
q = select(Post).where(*base_filters)
# ---- TAG FILTER (matches any tag on the post)
if selected_tags:
tag_slugs = list(selected_tags)
q = q.where(
Post.tags.any(
and_(
Tag.slug.in_(tag_slugs),
Tag.deleted_at.is_(None),
)
)
)
# ---- EXCLUDE-COVERED FILTER ("etc" mode: posts NOT covered by any group)
if exclude_covered_tag_ids:
covered_sq = (
select(PostTag.post_id)
.join(Tag, Tag.id == PostTag.tag_id)
.where(
Tag.id.in_(list(exclude_covered_tag_ids)),
Tag.deleted_at.is_(None),
)
)
q = q.where(Post.id.notin_(covered_sq))
# ---- AUTHOR FILTER (matches primary or any author)
if selected_authors:
author_slugs = list(selected_authors)
q = q.where(
or_(
Post.primary_author.has(
and_(
Author.slug.in_(author_slugs),
Author.deleted_at.is_(None),
)
),
Post.authors.any(
and_(
Author.slug.in_(author_slugs),
Author.deleted_at.is_(None),
)
),
)
)
# ---- SEARCH FILTER (title OR excerpt OR plaintext contains)
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),
)
)
# ---- ordering
if drafts:
q = q.order_by(desc(Post.updated_at))
else:
q = q.order_by(desc(Post.published_at))
# ---- pagination math
if page < 1:
page = 1
offset_val = (page - 1) * limit
# ---- total count with SAME filters (including tag/author/search)
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)
# ---- eager load relationships to avoid N+1 / greenlet issues
q = (
q.options(
joinedload(Post.primary_author),
joinedload(Post.primary_tag),
selectinload(Post.authors),
selectinload(Post.tags),
)
.limit(limit)
.offset(offset_val)
)
rows: List[Post] = list((await self.sess.execute(q)).scalars())
posts = [_post_to_public(p) for p in rows]
# ---- search_count: reflect same filters + search (i.e., equals total once filters applied)
search_count = total
pages_total = (total + limit - 1) // limit if limit else 1
pagination = {
"page": page,
"limit": limit,
"pages": pages_total,
"total": total,
"search_count": search_count,
"next": page + 1 if page < pages_total else None,
"prev": page - 1 if page > 1 else None,
}
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,
include: Sequence[str] = ("tags", "authors"),
fields: Sequence[str] = (
"id",
"slug",
"title",
"html",
"excerpt",
"custom_excerpt",
"published_at",
"feature_image",
),
include_drafts: bool = False,
) -> List[Dict[str, Any]]:
"""
Return posts (usually 1) matching this slug.
Only returns published, non-deleted posts by default.
When include_drafts=True, also returns draft posts (for admin access).
Eager-load related objects via selectinload/joinedload so we don't N+1 when
serializing in _post_to_public().
"""
# Build .options(...) dynamically based on `include`
load_options = []
# Tags
if "tags" in include:
load_options.append(selectinload(Post.tags))
if hasattr(Post, "primary_tag"):
# joinedload is fine too; selectin keeps a single extra roundtrip
load_options.append(selectinload(Post.primary_tag))
# Authors
if "authors" in include:
if hasattr(Post, "primary_author"):
load_options.append(selectinload(Post.primary_author))
if hasattr(Post, "authors"):
load_options.append(selectinload(Post.authors))
filters = [Post.deleted_at.is_(None), Post.slug == slug]
if not include_drafts:
filters.append(Post.status == "published")
q = (
select(Post)
.where(*filters)
.order_by(desc(Post.published_at))
.options(*load_options)
)
result = await self.sess.execute(q)
rows: List[Post] = list(result.scalars())
return [(_post_to_public(p), p) for p in rows]
async def list_tags(
self,
limit: int = 5000,
page: int = 1,
is_page=False,
) -> List[Dict[str, Any]]:
"""
Return public, not-soft-deleted tags.
Include published_post_count = number of published (not deleted) posts using that tag.
"""
if page < 1:
page = 1
offset_val = (page - 1) * limit
# Subquery: count published posts per tag
tag_post_counts_sq = (
select(
PostTag.tag_id.label("tag_id"),
func.count().label("published_post_count"),
)
.select_from(PostTag)
.join(Post, Post.id == PostTag.post_id)
.where(
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(is_page),
)
.group_by(PostTag.tag_id)
.subquery()
)
q = (
select(
Tag,
func.coalesce(tag_post_counts_sq.c.published_post_count, 0).label(
"published_post_count"
),
)
.outerjoin(
tag_post_counts_sq,
tag_post_counts_sq.c.tag_id == Tag.id,
)
.where(
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
func.coalesce(tag_post_counts_sq.c.published_post_count, 0) > 0,
)
.order_by(desc(func.coalesce(tag_post_counts_sq.c.published_post_count, 0)), asc(Tag.name))
.limit(limit)
.offset(offset_val)
)
result = await self.sess.execute(q)
# result will return rows like (Tag, published_post_count)
rows = list(result.all())
tags = [
{
"id": tag.ghost_id,
"slug": tag.slug,
"name": tag.name,
"description": tag.description,
"feature_image": tag.feature_image,
"visibility": tag.visibility,
"published_post_count": count,
}
for (tag, count) in rows
]
return tags
async def list_authors(
self,
limit: int = 5000,
page: int = 1,
is_page=False,
) -> List[Dict[str, Any]]:
"""
Return non-deleted authors.
Include published_post_count = number of published (not deleted) posts by that author
(counted via Post.primary_author_id).
"""
if page < 1:
page = 1
offset_val = (page - 1) * limit
# Subquery: count published posts per primary author
author_post_counts_sq = (
select(
Post.primary_author_id.label("author_id"),
func.count().label("published_post_count"),
)
.where(
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(is_page),
)
.group_by(Post.primary_author_id)
.subquery()
)
q = (
select(
Author,
func.coalesce(author_post_counts_sq.c.published_post_count, 0).label(
"published_post_count"
),
)
.outerjoin(
author_post_counts_sq,
author_post_counts_sq.c.author_id == Author.id,
)
.where(
Author.deleted_at.is_(None),
)
.order_by(asc(Author.name))
.limit(limit)
.offset(offset_val)
)
result = await self.sess.execute(q)
rows = list(result.all())
authors = [
{
"id": a.ghost_id,
"slug": a.slug,
"name": a.name,
"bio": a.bio,
"profile_image": a.profile_image,
"cover_image": a.cover_image,
"website": a.website,
"location": a.location,
"facebook": a.facebook,
"twitter": a.twitter,
"published_post_count": count,
}
for (a, count) in rows
]
return authors
async def count_drafts(self, user_id: Optional[int] = None) -> int:
"""Count draft (non-page, non-deleted) posts, optionally for a single user."""
q = select(func.count()).select_from(Post).where(
Post.deleted_at.is_(None),
Post.status == "draft",
Post.is_page.is_(False),
)
if user_id is not None:
q = q.where(Post.user_id == user_id)
return int((await self.sess.execute(q)).scalar() or 0)
async def list_tag_groups_with_counts(self) -> List[Dict[str, Any]]:
"""
Return all tag groups with aggregated published post counts.
Each group dict includes a `tag_slugs` list and `tag_ids` list.
Count = distinct published posts having ANY member tag.
Ordered by sort_order, name.
"""
# Subquery: distinct published post IDs per tag group
post_count_sq = (
select(
TagGroupTag.tag_group_id.label("group_id"),
func.count(func.distinct(PostTag.post_id)).label("post_count"),
)
.select_from(TagGroupTag)
.join(PostTag, PostTag.tag_id == TagGroupTag.tag_id)
.join(Post, Post.id == PostTag.post_id)
.where(
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(False),
)
.group_by(TagGroupTag.tag_group_id)
.subquery()
)
q = (
select(
TagGroup,
func.coalesce(post_count_sq.c.post_count, 0).label("post_count"),
)
.outerjoin(post_count_sq, post_count_sq.c.group_id == TagGroup.id)
.order_by(asc(TagGroup.sort_order), asc(TagGroup.name))
)
rows = list((await self.sess.execute(q)).all())
groups = []
for tg, count in rows:
# Fetch member tag slugs + ids for this group
tag_rows = list(
(await self.sess.execute(
select(Tag.slug, Tag.id)
.join(TagGroupTag, TagGroupTag.tag_id == Tag.id)
.where(
TagGroupTag.tag_group_id == tg.id,
Tag.deleted_at.is_(None),
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
)
)).all()
)
groups.append({
"id": tg.id,
"name": tg.name,
"slug": tg.slug,
"feature_image": tg.feature_image,
"colour": tg.colour,
"sort_order": tg.sort_order,
"post_count": count,
"tag_slugs": [r[0] for r in tag_rows],
"tag_ids": [r[1] for r in tag_rows],
})
return groups
async def count_etc_posts(self, assigned_tag_ids: List[int]) -> int:
"""
Count published posts not covered by any tag group.
Includes posts with no tags and posts whose tags are all unassigned.
"""
base = [
Post.deleted_at.is_(None),
Post.published_at.is_not(None),
Post.is_page.is_(False),
]
if assigned_tag_ids:
covered_sq = (
select(PostTag.post_id)
.join(Tag, Tag.id == PostTag.tag_id)
.where(
Tag.id.in_(assigned_tag_ids),
Tag.deleted_at.is_(None),
)
)
base.append(Post.id.notin_(covered_sq))
q = select(func.count()).select_from(Post).where(*base)
return int((await self.sess.execute(q)).scalar() or 0)
async def list_drafts(self) -> List[Dict[str, Any]]:
"""Return all draft (non-page, non-deleted) posts, newest-updated first."""
q = (
select(Post)
.where(
Post.deleted_at.is_(None),
Post.status == "draft",
Post.is_page.is_(False),
)
.order_by(desc(Post.updated_at))
.options(
joinedload(Post.primary_author),
joinedload(Post.primary_tag),
selectinload(Post.authors),
selectinload(Post.tags),
)
)
rows: List[Post] = list((await self.sess.execute(q)).scalars())
return [_post_to_public(p) for p in rows]

369
blog/bp/blog/routes.py Normal file
View File

@@ -0,0 +1,369 @@
from __future__ import annotations
#from quart import Blueprint, g
import json
import os
from quart import (
request,
render_template,
make_response,
g,
Blueprint,
redirect,
url_for,
)
from .ghost_db import DBClient # adjust import path
from shared.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 shared.browser.app.redis_cacher import cache_page, invalidate_tag_cache
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.authz import require_admin
from shared.utils import host_url
def register(url_prefix, title):
blogs_bp = Blueprint("blog", __name__, url_prefix)
from .web_hooks.routes import ghost_webhooks
blogs_bp.register_blueprint(ghost_webhooks)
from .ghost.editor_api import editor_api_bp
blogs_bp.register_blueprint(editor_api_bp)
from ..post.routes import register as register_blog
blogs_bp.register_blueprint(
register_blog(),
)
from .admin.routes import register as register_tag_groups_admin
blogs_bp.register_blueprint(register_tag_groups_admin())
@blogs_bp.before_app_serving
async def init():
from .ghost.ghost_sync import (
sync_all_content_from_ghost,
sync_all_membership_from_ghost,
)
async with get_session() as s:
await sync_all_content_from_ghost(s)
await sync_all_membership_from_ghost(s)
await s.commit()
@blogs_bp.before_request
def route():
g.makeqs_factory = makeqs_factory
@blogs_bp.context_processor
async def inject_root():
return {
"blog_title": title,
"qs": makeqs_factory()(),
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
}
SORT_MAP = {
"newest": "published_at DESC",
"oldest": "published_at ASC",
"az": "title ASC",
"za": "title DESC",
"featured": "featured DESC, published_at DESC",
}
@blogs_bp.get("/")
async def home():
"""Render the Ghost page with slug 'home' as the site homepage."""
from ..post.services.post_data import post_data as _post_data
from shared.config import config as get_config
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services as svc
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
p_data = await _post_data("home", g.s, include_drafts=False)
if not p_data:
# Fall back to blog index if "home" page doesn't exist yet
return redirect(host_url(url_for("blog.index")))
g.post_data = p_data
# Build the same context the post blueprint's context_processor provides
db_post_id = p_data["post"]["id"]
post_slug = p_data["post"]["slug"]
# Fetch container nav fragments from events + market
paginate_url = url_for(
'blog.post.widget_paginate',
slug=post_slug, widget_domain='calendar',
)
nav_params = {
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
"paginate_url": paginate_url,
}
events_nav_html, market_nav_html = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
])
container_nav_html = events_nav_html + market_nav_html
ctx = {
**p_data,
"base_title": f"{get_config()['title']} {p_data['post']['title']}",
"container_nav_html": container_nav_html,
}
# Page cart badge
if p_data["post"].get("is_page"):
ident = current_cart_identity()
page_summary = await svc.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_slug,
)
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
if not is_htmx_request():
html = await render_template("_types/home/index.html", **ctx)
else:
html = await render_template("_types/home/_oob_elements.html", **ctx)
return await make_response(html)
@blogs_bp.get("/index")
@blogs_bp.get("/index/")
async def index():
"""Blog listing — moved from / to /index."""
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,
"selected_tags": (),
"selected_authors": (),
"selected_groups": (),
"sort": None,
"view": None,
"drafts": None,
"draft_count": 0,
"tags": [],
"authors": [],
"tag_groups": [],
"posts": data.get("pages", []),
}
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"))
drafts_user_id = None if (not show_drafts or is_admin) else g.user.id
# For the draft count badge: admin sees all drafts, non-admin sees own
count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False)
data = await posts_data(
g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked,
drafts=show_drafts, drafts_user_id=drafts_user_id,
count_drafts_for_user_id=count_drafts_uid,
selected_groups=q.selected_groups,
)
context = {
**data,
"content_type": "posts",
"selected_tags": q.selected_tags,
"selected_authors": q.selected_authors,
"selected_groups": q.selected_groups,
"sort": q.sort,
"search": q.search,
"view": q.view,
"drafts": q.drafts if show_drafts else None,
}
# Determine which template to use based on request type and pagination
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/blog/index.html", **context)
elif q.page > 1:
# HTMX pagination: just blog cards + sentinel
html = await render_template("_types/blog/_cards.html", **context)
else:
# HTMX navigation (page 1): main panel + OOB elements
#main_panel = await render_template("_types/blog/_main_panel.html", **context)
html = await render_template("_types/blog/_oob_elements.html", **context)
#html = oob_elements + main_panel
return await make_response(html)
@blogs_bp.get("/new/")
@require_admin
async def new_post():
if not is_htmx_request():
html = await render_template("_types/blog_new/index.html")
else:
html = await render_template("_types/blog_new/_oob_elements.html")
return await make_response(html)
@blogs_bp.post("/new/")
@require_admin
async def new_post_save():
from .ghost.ghost_posts import create_post
from .ghost.lexical_validator import validate_lexical
from .ghost.ghost_sync import sync_single_post
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.",
)
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,
)
return await make_response(html, 400)
# Create in Ghost
ghost_post = await create_post(
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
await sync_single_post(g.s, ghost_post["id"])
await g.s.flush()
# Set user_id on the newly created post
from models.ghost_content import Post
from sqlalchemy import select
local_post = (await g.s.execute(
select(Post).where(Post.ghost_id == ghost_post["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 edit page (post is likely a draft, so public detail would 404)
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.index")) + "?drafts=1")
return blogs_bp

View 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,
}

View File

@@ -0,0 +1,142 @@
import re
from ..ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from shared.infrastructure.fragments import fetch_fragment
from quart import g
async def posts_data(
session,
page, search, sort, selected_tags, selected_authors, liked,
drafts=False, drafts_user_id=None, count_drafts_for_user_id=None,
selected_groups=(),
):
client = DBClient(session)
# --- Tag-group resolution ---
tag_groups = await client.list_tag_groups_with_counts()
# Collect all assigned tag IDs across groups
all_assigned_tag_ids = []
for grp in tag_groups:
all_assigned_tag_ids.extend(grp["tag_ids"])
# Build slug-lookup for groups
group_by_slug = {grp["slug"]: grp for grp in tag_groups}
# Resolve selected group → post filtering
# Groups and tags are mutually exclusive — groups override tags when set
effective_tags = selected_tags
etc_mode_tag_ids = None # set when "etc" is selected
if selected_groups:
group_slug = selected_groups[0]
if group_slug == "etc":
# etc = posts NOT covered by any group (includes untagged)
etc_mode_tag_ids = all_assigned_tag_ids
effective_tags = ()
elif group_slug in group_by_slug:
effective_tags = tuple(group_by_slug[group_slug]["tag_slugs"])
# Compute "etc" virtual group
etc_count = await client.count_etc_posts(all_assigned_tag_ids)
if etc_count > 0 or (selected_groups and selected_groups[0] == "etc"):
tag_groups.append({
"id": None,
"name": "etc",
"slug": "etc",
"feature_image": None,
"colour": None,
"sort_order": 999999,
"post_count": etc_count,
"tag_slugs": [],
"tag_ids": [],
})
posts, pagination = await client.list_posts(
limit=10,
page=page,
selected_tags=effective_tags,
selected_authors=selected_authors,
search=search,
drafts=drafts,
drafts_user_id=drafts_user_id,
exclude_covered_tag_ids=etc_mode_tag_ids,
)
# Get all post IDs in this batch
post_ids = [p["id"] for p in posts]
# Add is_liked field to each post for current user
if g.user:
# Fetch all likes for this user and these posts in one query
liked_posts = await session.execute(
select(PostLike.post_id).where(
PostLike.user_id == g.user.id,
PostLike.post_id.in_(post_ids),
PostLike.deleted_at.is_(None),
)
)
liked_post_ids = {row[0] for row in liked_posts}
# Add is_liked to each post
for post in posts:
post["is_liked"] = post["id"] in liked_post_ids
else:
# Not logged in - no posts are liked
for post in posts:
post["is_liked"] = False
# Fetch card decoration fragments from events
card_widgets_html = {}
if post_ids:
post_slugs = [p.get("slug", "") for p in posts]
cards_html = await fetch_fragment("events", "container-cards", params={
"post_ids": ",".join(str(pid) for pid in post_ids),
"post_slugs": ",".join(post_slugs),
})
if cards_html:
card_widgets_html = _parse_card_fragments(cards_html)
tags=await client.list_tags(
limit=50000
)
authors=await client.list_authors(
limit=50000
)
# Draft count for the logged-in user (None → admin sees all)
draft_count = 0
if count_drafts_for_user_id is not False:
draft_count = await client.count_drafts(user_id=count_drafts_for_user_id)
return {
"posts": posts,
"page": pagination.get("page", page),
"total_pages": pagination.get("pages", 1),
"search_count": pagination.get("search_count"),
"tags": tags,
"authors": authors,
"draft_count": draft_count,
"tag_groups": tag_groups,
"selected_groups": selected_groups,
"card_widgets_html": card_widgets_html,
}
# Regex to extract per-post blocks delimited by comment markers
_CARD_MARKER_RE = re.compile(
r'<!-- card-widget:(\d+) -->(.*?)<!-- /card-widget:\1 -->',
re.DOTALL,
)
def _parse_card_fragments(html: str) -> dict[str, str]:
"""Parse the container-cards fragment into {post_id_str: html} dict."""
result = {}
for m in _CARD_MARKER_RE.finditer(html):
post_id_str = m.group(1)
inner = m.group(2).strip()
if inner:
result[post_id_str] = inner
return result

View File

@@ -0,0 +1,120 @@
# suma_browser/webhooks.py
from __future__ import annotations
import os
from quart import Blueprint, request, abort, Response, g
from ..ghost.ghost_sync import (
sync_single_member,
sync_single_page,
sync_single_post,
sync_single_author,
sync_single_tag,
)
from shared.browser.app.redis_cacher import clear_cache
from shared.browser.app.csrf import csrf_exempt
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
def _check_secret(req) -> None:
expected = os.getenv("GHOST_WEBHOOK_SECRET")
if not expected:
# if you don't set a secret, we allow anything (dev mode)
return
got = req.args.get("secret") or req.headers.get("X-Webhook-Secret")
if got != expected:
abort(401)
def _extract_id(data: dict, key: str) -> str | None:
"""
key is "post", "tag", or "user"/"author".
Ghost usually sends { key: { current: { id: ... }, previous: { id: ... } } }
We'll try current first, then previous.
"""
block = data.get(key) or {}
cur = block.get("current") or {}
prev = block.get("previous") or {}
return cur.get("id") or prev.get("id")
@csrf_exempt
@ghost_webhooks.route("/member/", methods=["POST"])
#@ghost_webhooks.post("/member/")
async def webhook_member() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "member")
if not ghost_id:
abort(400, "no member id")
# sync one post
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py
await sync_single_member(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/post/")
@clear_cache(tag='blog')
async def webhook_post() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "post")
if not ghost_id:
abort(400, "no post id")
# sync one post
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py
await sync_single_post(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/page/")
@clear_cache(tag='blog')
async def webhook_page() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "page")
if not ghost_id:
abort(400, "no page id")
# sync one post
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"] # we'll set this in app.py
await sync_single_page(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/author/")
@clear_cache(tag='blog')
async def webhook_author() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
# Ghost calls them "user" in webhook payload in many versions,
# and you want authors in your mirror. We'll try both keys.
ghost_id = _extract_id(data, "user") or _extract_id(data, "author")
if not ghost_id:
abort(400, "no author id")
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"]
await sync_single_author(g.s, ghost_id)
return Response(status=204)
@csrf_exempt
@ghost_webhooks.post("/tag/")
@clear_cache(tag='blog')
async def webhook_tag() -> Response:
_check_secret(request)
data = await request.get_json(force=True, silent=True) or {}
ghost_id = _extract_id(data, "tag")
if not ghost_id:
abort(400, "no tag id")
#async_session_factory = request.app.config["ASYNC_SESSION_FACTORY"]
await sync_single_tag(g.s, ghost_id)
return Response(status=204)

View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

View File

@@ -0,0 +1,52 @@
"""Blog app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.navigation import get_navigation_tree
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# Registry of fragment handlers: type -> async callable returning HTML str
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
# --- nav-tree fragment ---
async def _nav_tree_handler():
app_name = request.args.get("app_name", "")
path = request.args.get("path", "/")
first_seg = path.strip("/").split("/")[0]
menu_items = await get_navigation_tree(g.s)
return await render_template(
"fragments/nav_tree.html",
menu_items=menu_items,
frag_app_name=app_name,
frag_first_seg=first_seg,
)
_handlers["nav-tree"] = _nav_tree_handler
# Store handlers dict on blueprint so app code can register handlers
bp._fragment_handlers = _handlers
return bp

View File

@@ -0,0 +1,3 @@
from .routes import register
__all__ = ["register"]

View File

@@ -0,0 +1,213 @@
from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, jsonify, g
from shared.browser.app.authz import require_admin
from .services.menu_items import (
get_all_menu_items,
get_menu_item_by_id,
create_menu_item,
update_menu_item,
delete_menu_item,
search_pages,
MenuItemError,
)
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
async def get_menu_items_nav_oob():
"""Helper to generate OOB update for root nav menu items"""
menu_items = await get_all_menu_items(g.s)
nav_oob = await render_template(
"_types/menu_items/_nav_oob.html",
menu_items=menu_items,
)
return nav_oob
@bp.get("/")
@require_admin
async def list_menu_items():
"""List all menu items"""
menu_items = await get_all_menu_items(g.s)
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/menu_items/index.html",
menu_items=menu_items,
)
else:
html = await render_template(
"_types/menu_items/_oob_elements.html",
menu_items=menu_items,
)
#html = await render_template("_types/root/settings/_oob_elements.html")
return await make_response(html)
@bp.get("/new/")
@require_admin
async def new_menu_item():
"""Show form to create new menu item"""
html = await render_template(
"_types/menu_items/_form.html",
menu_item=None,
)
return await make_response(html)
@bp.post("/")
@require_admin
async def create_menu_item_route():
"""Create a new menu item"""
form = await request.form
post_id = form.get("post_id")
if not post_id:
return jsonify({"message": "Page is required", "errors": {"post_id": ["Please select a page"]}}), 422
try:
post_id = int(post_id)
except ValueError:
return jsonify({"message": "Invalid page ID", "errors": {"post_id": ["Invalid page"]}}), 422
try:
menu_item = await create_menu_item(g.s, post_id)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@bp.get("/<int:item_id>/edit/")
@require_admin
async def edit_menu_item(item_id: int):
"""Show form to edit menu item"""
menu_item = await get_menu_item_by_id(g.s, item_id)
if not menu_item:
return await make_response("Menu item not found", 404)
html = await render_template(
"_types/menu_items/_form.html",
menu_item=menu_item,
)
return await make_response(html)
@bp.put("/<int:item_id>/")
@require_admin
async def update_menu_item_route(item_id: int):
"""Update a menu item"""
form = await request.form
post_id = form.get("post_id")
if not post_id:
return jsonify({"message": "Page is required", "errors": {"post_id": ["Please select a page"]}}), 422
try:
post_id = int(post_id)
except ValueError:
return jsonify({"message": "Invalid page ID", "errors": {"post_id": ["Invalid page"]}}), 422
try:
menu_item = await update_menu_item(g.s, item_id, post_id=post_id)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
except MenuItemError as e:
return jsonify({"message": str(e), "errors": {}}), 400
@bp.delete("/<int:item_id>/")
@require_admin
async def delete_menu_item_route(item_id: int):
"""Delete a menu item"""
success = await delete_menu_item(g.s, item_id)
if not success:
return await make_response("Menu item not found", 404)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
@bp.get("/pages/search/")
@require_admin
async def search_pages_route():
"""Search for pages to add as menu items"""
query = request.args.get("q", "").strip()
page = int(request.args.get("page", 1))
per_page = 10
pages, total = await search_pages(g.s, query, page, per_page)
has_more = (page * per_page) < total
html = await render_template(
"_types/menu_items/_page_search_results.html",
pages=pages,
query=query,
page=page,
has_more=has_more,
)
return await make_response(html)
@bp.post("/reorder/")
@require_admin
async def reorder_menu_items_route():
"""Reorder menu items"""
from .services.menu_items import reorder_menu_items
form = await request.form
item_ids_str = form.get("item_ids", "")
if not item_ids_str:
return jsonify({"message": "No items to reorder", "errors": {}}), 400
try:
item_ids = [int(id.strip()) for id in item_ids_str.split(",") if id.strip()]
except ValueError:
return jsonify({"message": "Invalid item IDs", "errors": {}}), 400
await reorder_menu_items(g.s, item_ids)
await g.s.flush()
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
return await make_response(html + nav_oob, 200)
return bp

View File

@@ -0,0 +1,209 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.models.menu_node import MenuNode
from models.ghost_content import Post
from shared.services.relationships import attach_child, detach_child
class MenuItemError(ValueError):
"""Base error for menu item service operations."""
async def get_all_menu_items(session: AsyncSession) -> list[MenuNode]:
"""
Get all menu nodes (excluding deleted), ordered by sort_order.
"""
result = await session.execute(
select(MenuNode)
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
.order_by(MenuNode.sort_order.asc(), MenuNode.id.asc())
)
return list(result.scalars().all())
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuNode | None:
"""Get a menu node by ID (excluding deleted)."""
result = await session.execute(
select(MenuNode)
.where(MenuNode.id == item_id, MenuNode.deleted_at.is_(None))
)
return result.scalar_one_or_none()
async def create_menu_item(
session: AsyncSession,
post_id: int,
sort_order: int | None = None
) -> MenuNode:
"""
Create a MenuNode + ContainerRelation for a page.
If sort_order is not provided, adds to end of list.
"""
# Verify post exists and is a page
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
if not post:
raise MenuItemError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MenuItemError("Only pages can be added as menu items, not posts.")
# If no sort_order provided, add to end
if sort_order is None:
max_order = await session.scalar(
select(func.max(MenuNode.sort_order))
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
)
sort_order = (max_order or 0) + 1
# Check for duplicate (same page, not deleted)
existing = await session.scalar(
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError("Menu item for this page already exists.")
menu_node = MenuNode(
container_type="page",
container_id=post_id,
label=post.title,
slug=post.slug,
feature_image=post.feature_image,
sort_order=sort_order,
)
session.add(menu_node)
await session.flush()
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
return menu_node
async def update_menu_item(
session: AsyncSession,
item_id: int,
post_id: int | None = None,
sort_order: int | None = None
) -> MenuNode:
"""Update an existing menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
raise MenuItemError(f"Menu item {item_id} not found.")
if post_id is not None:
# Verify post exists and is a page
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
if not post:
raise MenuItemError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MenuItemError("Only pages can be added as menu items, not posts.")
# Check for duplicate (same page, different menu node)
existing = await session.scalar(
select(MenuNode).where(
MenuNode.container_type == "page",
MenuNode.container_id == post_id,
MenuNode.id != item_id,
MenuNode.deleted_at.is_(None),
)
)
if existing:
raise MenuItemError("Menu item for this page already exists.")
old_post_id = menu_node.container_id
menu_node.container_id = post_id
menu_node.label = post.title
menu_node.slug = post.slug
menu_node.feature_image = post.feature_image
if sort_order is not None:
menu_node.sort_order = sort_order
await session.flush()
if post_id is not None and post_id != old_post_id:
await detach_child(session, "page", old_post_id, "menu_node", menu_node.id)
await attach_child(session, "page", post_id, "menu_node", menu_node.id)
return menu_node
async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
"""Soft delete a menu node."""
menu_node = await get_menu_item_by_id(session, item_id)
if not menu_node:
return False
menu_node.deleted_at = func.now()
await session.flush()
await detach_child(session, "page", menu_node.container_id, "menu_node", menu_node.id)
return True
async def reorder_menu_items(
session: AsyncSession,
item_ids: list[int]
) -> list[MenuNode]:
"""
Reorder menu nodes by providing a list of IDs in desired order.
Updates sort_order for each node.
"""
items = []
for index, item_id in enumerate(item_ids):
menu_node = await get_menu_item_by_id(session, item_id)
if menu_node:
menu_node.sort_order = index
items.append(menu_node)
await session.flush()
return items
async def search_pages(
session: AsyncSession,
query: str,
page: int = 1,
per_page: int = 10
) -> tuple[list[Post], int]:
"""
Search for pages (not posts) by title.
Returns (pages, total_count).
"""
filters = [
Post.is_page == True, # noqa: E712
Post.status == "published",
Post.deleted_at.is_(None)
]
if query:
filters.append(Post.title.ilike(f"%{query}%"))
# Get total count
count_result = await session.execute(
select(func.count(Post.id)).where(*filters)
)
total = count_result.scalar() or 0
# Get paginated results
offset = (page - 1) * per_page
result = await session.execute(
select(Post)
.where(*filters)
.order_by(Post.title.asc())
.limit(per_page)
.offset(offset)
)
pages = list(result.scalars().all())
return pages, total

View File

@@ -0,0 +1,688 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
Blueprint,
g,
request,
redirect,
url_for,
)
from shared.browser.app.authz import require_admin, require_post_author
from shared.browser.app.utils.htmx import is_htmx_request
from shared.utils import host_url
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 shared.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 = {}
sumup_configured = False
sumup_merchant_code = ""
sumup_checkout_prefix = ""
if post.get("is_page"):
pc = (await g.s.execute(
sa_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 ""
ctx = {
"features": features,
"sumup_configured": sumup_configured,
"sumup_merchant_code": sumup_merchant_code,
"sumup_checkout_prefix": sumup_checkout_prefix,
}
# 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", **ctx)
else:
# HTMX request: main panel + OOB elements
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 shared.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 or create PageConfig
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
from shared.services.relationships import attach_child
await attach_child(g.s, "page", post_id, "page_config", pc.id)
# 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,
sumup_configured=bool(pc.sumup_api_key),
sumup_merchant_code=pc.sumup_merchant_code or "",
sumup_checkout_prefix=pc.sumup_checkout_prefix or "",
)
return await make_response(html)
@bp.put("/admin/sumup/")
@require_admin
async def update_sumup(slug: str):
"""Update PageConfig SumUp credentials for a page."""
from shared.models.page_config import PageConfig
from sqlalchemy import select as sa_select
from quart import jsonify
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"]
pc = (await g.s.execute(
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
from shared.services.relationships import attach_child
await attach_child(g.s, "page", post_id, "page_config", pc.id)
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip()
pc.sumup_merchant_code = merchant_code or None
pc.sumup_checkout_prefix = checkout_prefix or None
# Only update API key if non-empty (allows updating other fields without re-entering key)
if api_key:
pc.sumup_api_key = api_key
await g.s.flush()
features = pc.features or {}
html = await render_template(
"_types/post/admin/_features_panel.html",
features=features,
post=post,
sumup_configured=bool(pc.sumup_api_key),
sumup_merchant_code=pc.sumup_merchant_code or "",
sumup_checkout_prefix=pc.sumup_checkout_prefix or "",
)
return await make_response(html)
@bp.get("/data/")
@require_admin
async def data(slug: str):
if not is_htmx_request():
html = await render_template(
"_types/post_data/index.html",
)
else:
html = await render_template(
"_types/post_data/_oob_elements.html",
)
return await make_response(html)
@bp.get("/entries/calendar/<int:calendar_id>/")
@require_admin
async def calendar_view(slug: str, calendar_id: int):
"""Show calendar month view for browsing entries"""
from shared.models.calendars import Calendar
from shared.utils.calendar_helpers import parse_int_arg, add_months, build_calendar_weeks
from shared.services.registry import services
from sqlalchemy import select
from datetime import datetime, timezone
import calendar as pycalendar
from quart import session as qsession
from ..services.entry_associations import get_post_entry_ids
# Get month/year from query params
today = datetime.now(timezone.utc).date()
month = parse_int_arg("month")
year = parse_int_arg("year")
if year is None:
year = today.year
if month is None or not (1 <= month <= 12):
month = today.month
# Load calendar
result = await g.s.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.deleted_at.is_(None))
)
calendar_obj = result.scalar_one_or_none()
if not calendar_obj:
return await make_response("Calendar not found", 404)
# Build calendar data
prev_month_year, prev_month = add_months(year, month, -1)
next_month_year, next_month = add_months(year, month, +1)
prev_year = year - 1
next_year = year + 1
weeks = build_calendar_weeks(year, month)
month_name = pycalendar.month_name[month]
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
# Get entries for this month
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
next_y, next_m = add_months(year, month, +1)
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
user = getattr(g, "user", None)
user_id = user.id if user else None
is_admin = bool(user and getattr(user, "is_admin", False))
session_id = qsession.get("calendar_sid")
month_entries = await services.calendar.visible_entries_for_period(
g.s, calendar_obj.id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
# Get associated entry IDs for this post
post_id = g.post_data["post"]["id"]
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
html = await render_template(
"_types/post/admin/_calendar_view.html",
calendar=calendar_obj,
year=year,
month=month,
month_name=month_name,
weekday_names=weekday_names,
weeks=weeks,
prev_month=prev_month,
prev_month_year=prev_month_year,
next_month=next_month,
next_month_year=next_month_year,
prev_year=prev_year,
next_year=next_year,
month_entries=month_entries,
associated_entry_ids=associated_entry_ids,
)
return await make_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(g.s, 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"])
if not is_htmx_request():
html = await render_template(
"_types/post_entries/index.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
else:
html = await render_template(
"_types/post_entries/_oob_elements.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
return await make_response(html)
@bp.post("/entries/<int:entry_id>/toggle/")
@require_admin
async def toggle_entry(slug: str, entry_id: int):
from ..services.entry_associations import toggle_entry_association, get_post_entry_ids, get_associated_entries
from shared.models.calendars import Calendar
from sqlalchemy import select
from quart import jsonify
post_id = g.post_data["post"]["id"]
is_associated, error = await toggle_entry_association(g.s, post_id, entry_id)
if error:
return jsonify({"message": error, "errors": {}}), 400
await g.s.flush()
# Return updated association status
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
# Load ALL 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"])
# Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, post_id)
# Load calendars for this post (for nav display)
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
admin_list = await render_template(
"_types/post/admin/_associated_entries.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
nav_entries_oob = await render_template(
"_types/post/admin/_nav_entries_oob.html",
associated_entries=associated_entries,
calendars=calendars,
post=g.post_data["post"],
)
return await make_response(admin_list + nav_entries_oob)
@bp.get("/settings/")
@require_post_author
async def settings(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1"
if not is_htmx_request():
html = await render_template(
"_types/post_settings/index.html",
ghost_post=ghost_post,
save_success=save_success,
)
else:
html = await render_template(
"_types/post_settings/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
)
return await make_response(html)
@bp.post("/settings/")
@require_post_author
async def settings_save(slug: str):
from ...blog.ghost.ghost_posts import update_post_settings
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
form = await request.form
updated_at = form.get("updated_at", "")
# Build kwargs — only include fields that were submitted
kwargs: dict = {}
# Text fields
for field in (
"slug", "custom_template", "meta_title", "meta_description",
"canonical_url", "og_image", "og_title", "og_description",
"twitter_image", "twitter_title", "twitter_description",
"feature_image_alt",
):
val = form.get(field)
if val is not None:
kwargs[field] = val.strip()
# Select fields
visibility = form.get("visibility")
if visibility is not None:
kwargs["visibility"] = visibility
# Datetime
published_at = form.get("published_at", "").strip()
if published_at:
kwargs["published_at"] = published_at
# Checkbox fields: present = True, absent = False
kwargs["featured"] = form.get("featured") == "on"
kwargs["email_only"] = form.get("email_only") == "on"
# Tags — comma-separated string → list of {"name": "..."} dicts
tags_str = form.get("tags", "").strip()
if tags_str:
kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()]
else:
kwargs["tags"] = []
# Update in Ghost
await update_post_settings(
ghost_id=ghost_id,
updated_at=updated_at,
is_page=is_page,
**kwargs,
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1")
@bp.get("/edit/")
@require_post_author
async def edit(slug: str):
from ...blog.ghost.ghost_posts import get_post_for_edit
from shared.models.ghost_membership_entities import GhostNewsletter
from sqlalchemy import select as sa_select
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1"
newsletters = (await g.s.execute(
sa_select(GhostNewsletter).order_by(GhostNewsletter.name)
)).scalars().all()
if not is_htmx_request():
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
else:
html = await render_template(
"_types/post_edit/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
return await make_response(html)
@bp.post("/edit/")
@require_post_author
async def edit_save(slug: str):
import json
from ...blog.ghost.ghost_posts import update_post
from ...blog.ghost.lexical_validator import validate_lexical
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
from shared.browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
is_page = bool(g.post_data["post"].get("is_page"))
form = await request.form
title = form.get("title", "").strip()
lexical_raw = form.get("lexical", "")
updated_at = form.get("updated_at", "")
status = form.get("status", "draft")
publish_mode = form.get("publish_mode", "web")
newsletter_slug = form.get("newsletter_slug", "").strip() or None
feature_image = form.get("feature_image", "").strip()
custom_excerpt = form.get("custom_excerpt", "").strip()
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate the lexical JSON
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error="Invalid JSON in editor content.",
)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error=reason,
)
return await make_response(html, 400)
# Update in Ghost (content save — no status change yet)
ghost_post = await update_post(
ghost_id=ghost_id,
lexical_json=lexical_raw,
title=title or None,
updated_at=updated_at,
feature_image=feature_image,
custom_excerpt=custom_excerpt,
feature_image_caption=feature_image_caption,
is_page=is_page,
)
# Publish workflow
is_admin = bool((g.get("rights") or {}).get("admin"))
publish_requested_msg = None
# Guard: if already emailed, force publish_mode to "web" to prevent re-send
already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status"))
if already_emailed and publish_mode in ("email", "both"):
publish_mode = "web"
if status == "published" and ghost_post.get("status") != "published" and not is_admin:
# Non-admin requesting publish: don't send status to Ghost, set local flag
publish_requested_msg = "Publish requested — an admin will review."
elif status and status != ghost_post.get("status"):
# Status is changing — determine email params based on publish_mode
email_kwargs: dict = {}
if status == "published" and publish_mode in ("email", "both") and newsletter_slug:
email_kwargs["newsletter_slug"] = newsletter_slug
email_kwargs["email_segment"] = "all"
if publish_mode == "email":
email_kwargs["email_only"] = True
from ...blog.ghost.ghost_posts import update_post as _up
ghost_post = await _up(
ghost_id=ghost_id,
lexical_json=lexical_raw,
title=None,
updated_at=ghost_post["updated_at"],
status=status,
is_page=is_page,
**email_kwargs,
)
# Sync to local DB
if is_page:
await sync_single_page(g.s, ghost_id)
else:
await sync_single_post(g.s, ghost_id)
await g.s.flush()
# Handle publish_requested flag on the local post
from models.ghost_content import Post
from sqlalchemy import select as sa_select
local_post = (await g.s.execute(
sa_select(Post).where(Post.ghost_id == ghost_id)
)).scalar_one_or_none()
if local_post:
if publish_requested_msg:
local_post.publish_requested = True
elif status == "published" and is_admin:
local_post.publish_requested = False
await g.s.flush()
# Clear caches
await invalidate_tag_cache("blog")
await invalidate_tag_cache("post.post_detail")
# Redirect to GET to avoid resubmit warning on refresh (PRG pattern)
redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1"
if publish_requested_msg:
redirect_url += "&publish_requested=1"
return redirect(redirect_url)
@bp.get("/markets/")
@require_admin
async def markets(slug: str):
"""List markets for this page."""
from shared.services.registry import services
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
return await make_response(html)
@bp.post("/markets/new/")
@require_admin
async def create_market(slug: str):
"""Create a new market for this page."""
from ..services.markets import create_market as _create_market, MarketError
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return jsonify({"error": "Post not found"}), 404
form = await request.form
name = (form.get("name") or "").strip()
try:
await _create_market(g.s, post_id, name)
except MarketError as e:
return jsonify({"error": str(e)}), 400
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
return await make_response(html)
@bp.delete("/markets/<market_slug>/")
@require_admin
async def delete_market(slug: str, market_slug: str):
"""Soft-delete a market."""
from ..services.markets import soft_delete_market
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
deleted = await soft_delete_market(g.s, slug, market_slug)
if not deleted:
return jsonify({"error": "Market not found"}), 404
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
return await make_response(html)
return bp

180
blog/bp/post/routes.py Normal file
View File

@@ -0,0 +1,180 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
g,
Blueprint,
abort,
url_for,
request,
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from shared.services.registry import services
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.browser.app.redis_cacher import cache_page, clear_cache
from .admin.routes import register as register_admin
from shared.config import config
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("post", __name__, url_prefix='/<slug>')
bp.register_blueprint(
register_admin()
)
# Calendar blueprints now live in the events service.
# Post pages link to events_url() instead of embedding calendars.
@bp.url_value_preprocessor
def pull_blog(endpoint, values):
g.post_slug = values.get("slug")
@bp.before_request
async def hydrate_post_data():
slug = getattr(g, "post_slug", None)
if not slug:
return # not a blog route or no slug in this URL
is_admin = bool((g.get("rights") or {}).get("admin"))
# Always include drafts so we can check ownership below
p_data = await post_data(slug, g.s, include_drafts=True)
if not p_data:
abort(404)
return
# Access control for draft posts
if p_data["post"].get("status") != "published":
if is_admin:
pass # admin can see all drafts
elif g.user and p_data["post"].get("user_id") == g.user.id:
pass # author can see their own drafts
else:
abort(404)
return
g.post_data = p_data
@bp.context_processor
async def context():
p_data = getattr(g, "post_data", None)
if p_data:
from shared.infrastructure.cart_identity import current_cart_identity
db_post_id = (g.post_data.get("post") or {}).get("id")
post_slug = (g.post_data.get("post") or {}).get("slug", "")
# Fetch container nav fragments from events + market
paginate_url = url_for(
'blog.post.widget_paginate',
slug=post_slug, widget_domain='calendar',
)
nav_params = {
"container_type": "page",
"container_id": str(db_post_id),
"post_slug": post_slug,
"paginate_url": paginate_url,
}
events_nav_html, market_nav_html = await fetch_fragments([
("events", "container-nav", nav_params),
("market", "container-nav", nav_params),
])
container_nav_html = events_nav_html + market_nav_html
ctx = {
**p_data,
"base_title": f"{config()['title']} {p_data['post']['title']}",
"container_nav_html": container_nav_html,
}
# Page cart badge via service
post_dict = p_data.get("post") or {}
if post_dict.get("is_page"):
ident = current_cart_identity()
page_summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_dict["slug"],
)
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
return ctx
else:
return {}
@bp.get("/")
@cache_page(tag="post.post_detail")
async def post_detail(slug: str):
# 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/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/post/_oob_elements.html")
return await make_response(html)
@bp.post("/like/toggle/")
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from shared.utils import host_url
# Get post_id from g.post_data
if not g.user:
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=False,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
resp = make_response(html, 403)
return resp
post_id = g.post_data["post"]["id"]
user_id = g.user.id
liked, error = await toggle_post_like(g.s, user_id, post_id)
if error:
resp = make_response(error, 404)
return resp
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=liked,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
return html
@bp.get("/w/<widget_domain>/")
async def widget_paginate(slug: str, widget_domain: str):
"""Proxies paginated widget requests to the appropriate fragment provider."""
page = int(request.args.get("page", 1))
post_id = g.post_data["post"]["id"]
if widget_domain == "calendar":
html = await fetch_fragment("events", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": slug,
"page": str(page),
"paginate_url": url_for(
'blog.post.widget_paginate',
slug=slug, widget_domain='calendar',
),
})
return await make_response(html or "")
abort(404)
return bp

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.registry import services
async def toggle_entry_association(
session: AsyncSession,
post_id: int,
entry_id: int
) -> tuple[bool, str | None]:
"""
Toggle association between a post and calendar entry.
Returns (is_now_associated, error_message).
"""
post = await services.blog.get_post_by_id(session, post_id)
if not post:
return False, "Post not found"
is_associated = await services.calendar.toggle_entry_post(
session, entry_id, "post", post_id,
)
return is_associated, None
async def get_post_entry_ids(
session: AsyncSession,
post_id: int
) -> set[int]:
"""
Get all entry IDs associated with this post.
Returns a set of entry IDs.
"""
return await services.calendar.entry_ids_for_content(session, "post", post_id)
async def get_associated_entries(
session: AsyncSession,
post_id: int,
page: int = 1,
per_page: int = 10
) -> dict:
"""
Get paginated associated entries for this post.
Returns dict with entries (CalendarEntryDTOs), total_count, and has_more.
"""
entries, has_more = await services.calendar.associated_entries(
session, "post", post_id, page,
)
total_count = len(entries) + (page - 1) * per_page
if has_more:
total_count += 1 # at least one more
return {
"entries": entries,
"total_count": total_count,
"has_more": has_more,
"page": page,
}

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import re
import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.page_config import PageConfig
from shared.contracts.dtos import MarketPlaceDTO
from shared.services.registry import services
class MarketError(ValueError):
"""Base error for market service operations."""
def slugify(value: str, max_len: int = 255) -> str:
if value is None:
value = ""
value = unicodedata.normalize("NFKD", value)
value = value.encode("ascii", "ignore").decode("ascii")
value = value.lower()
value = value.replace("/", "-")
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value)
value = value.strip("-")[:max_len].strip("-")
return value or "market"
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
name = (name or "").strip()
if not name:
raise MarketError("Market name must not be empty.")
slug = slugify(name)
post = await services.blog.get_post_by_id(sess, post_id)
if not post:
raise MarketError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.")
pc = (await sess.execute(
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none()
if pc is None or not (pc.features or {}).get("market"):
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
try:
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
except ValueError as e:
raise MarketError(str(e)) from e
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
post = await services.blog.get_post_by_slug(sess, post_slug)
if not post:
return False
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)

View File

@@ -0,0 +1,42 @@
from ...blog.ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from quart import g
async def post_data(slug, session, include_drafts=False):
client = DBClient(session)
posts = (await client.posts_by_slug(slug, include_drafts=include_drafts))
if not posts:
# 404 page (you can make a template for this if you want)
return None
post, original_post = posts[0]
# Check if current user has liked this post
is_liked = False
if g.user:
liked_record = await session.scalar(
select(PostLike).where(
PostLike.user_id == g.user.id,
PostLike.post_id == post["id"],
PostLike.deleted_at.is_(None),
)
)
is_liked = liked_record is not None
# Add is_liked to post dict
post["is_liked"] = is_liked
tags=await client.list_tags(
limit=50000
) # <-- new
authors=await client.list_authors(
limit=50000
)
return {
"post": post,
"original_post": original_post,
"tags": tags,
"authors": authors,
}

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.ghost_content import Post, PostLike
async def toggle_post_like(
session: AsyncSession,
user_id: int,
post_id: int,
) -> tuple[bool, Optional[str]]:
"""
Toggle a post like for a given user using soft deletes.
Returns (liked_state, error_message).
- If error_message is not None, an error occurred.
- liked_state indicates whether post is now liked (True) or unliked (False).
"""
# Verify post exists
post_exists = await session.scalar(
select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None))
)
if not post_exists:
return False, "Post not found"
# Check if like exists (not deleted)
existing = await session.scalar(
select(PostLike).where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
)
if existing:
# Unlike: soft delete the like
await session.execute(
update(PostLike)
.where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
.values(deleted_at=func.now())
)
return False, None
else:
# Like: add a new like
new_like = PostLike(
user_id=user_id,
post_id=post_id,
)
session.add(new_like)
return True, None

View File

@@ -0,0 +1,3 @@
from .routes import register
__all__ = ["register"]

107
blog/bp/snippets/routes.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from shared.browser.app.authz import require_login
from shared.browser.app.utils.htmx import is_htmx_request
from models import Snippet
VALID_VISIBILITY = frozenset({"private", "shared", "admin"})
async def _visible_snippets(session):
"""Return snippets visible to the current user (own + shared + admin-if-admin)."""
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 session.execute(
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
)).scalars().all()
return rows
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."""
snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin")
if not is_htmx_request():
html = await render_template(
"_types/snippets/index.html",
snippets=snippets,
is_admin=is_admin,
)
else:
html = await render_template(
"_types/snippets/_oob_elements.html",
snippets=snippets,
is_admin=is_admin,
)
return await make_response(html)
@bp.delete("/<int:snippet_id>/")
@require_login
async def delete_snippet(snippet_id: int):
"""Delete a snippet. Owners delete their own; admins can delete any."""
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
abort(404)
is_admin = g.rights.get("admin")
if snippet.user_id != g.user.id and not is_admin:
abort(403)
await g.s.delete(snippet)
await g.s.flush()
snippets = await _visible_snippets(g.s)
html = await render_template(
"_types/snippets/_list.html",
snippets=snippets,
is_admin=is_admin,
)
return await make_response(html)
@bp.patch("/<int:snippet_id>/visibility/")
@require_login
async def patch_visibility(snippet_id: int):
"""Change snippet visibility. Admin only."""
if not g.rights.get("admin"):
abort(403)
snippet = await g.s.get(Snippet, snippet_id)
if not snippet:
abort(404)
form = await request.form
visibility = form.get("visibility", "").strip()
if visibility not in VALID_VISIBILITY:
abort(400)
snippet.visibility = visibility
await g.s.flush()
snippets = await _visible_snippets(g.s)
html = await render_template(
"_types/snippets/_list.html",
snippets=snippets,
is_admin=True,
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,84 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
blog: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
federation: "http://localhost:8004"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

32
blog/entrypoint.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Optional: wait for Postgres to be reachable
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
for i in {1..60}; do
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
sleep 1
done
fi
# Run DB migrations only if RUN_MIGRATIONS=true (blog service only)
if [[ "${RUN_MIGRATIONS:-}" == "true" ]]; then
echo "Running Alembic migrations..."
(cd shared && alembic upgrade head)
fi
# Clear Redis page cache on deploy
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
echo "Flushing Redis cache..."
python3 -c "
import redis, os
r = redis.from_url(os.environ['REDIS_URL'])
r.flushall()
print('Redis cache cleared.')
" || echo "Redis flush failed (non-fatal), continuing..."
fi
# Start the app
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000}

14
blog/models/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag
# Shared models — canonical definitions live in shared/models/
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from shared.models.menu_item import MenuItem
from shared.models.kv import KV
from shared.models.magic_link import MagicLink
from shared.models.user import User

View File

@@ -0,0 +1,3 @@
from shared.models.ghost_content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag, PostLike,
)

View File

@@ -0,0 +1,12 @@
# Re-export from canonical shared location
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
__all__ = [
"GhostLabel", "UserLabel",
"GhostNewsletter", "UserNewsletter",
"GhostTier", "GhostSubscription",
]

4
blog/models/kv.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.kv import KV
__all__ = ["KV"]

View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.magic_link import MagicLink
__all__ = ["MagicLink"]

4
blog/models/menu_item.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.menu_item import MenuItem
__all__ = ["MenuItem"]

32
blog/models/snippet.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
class Snippet(Base):
__tablename__ = "snippets"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
Index("ix_snippets_visibility", "visibility"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), nullable=False, default="private", server_default="private",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
)

52
blog/models/tag_group.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
func,
)
from shared.db.base import Base
class TagGroup(Base):
__tablename__ = "tag_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
feature_image: Mapped[Optional[str]] = mapped_column(Text())
colour: Mapped[Optional[str]] = mapped_column(String(32))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
tag_links: Mapped[List["TagGroupTag"]] = relationship(
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
)
class TagGroupTag(Base):
__tablename__ = "tag_group_tags"
__table_args__ = (
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tag_group_id: Mapped[int] = mapped_column(
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")

4
blog/models/user.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.user import User
__all__ = ["User"]

9
blog/path_setup.py Normal file
View File

@@ -0,0 +1,9 @@
import sys
import os
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

28
blog/services/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""Blog app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the blog app.
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
<tr><td>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
Click the button below to sign in. This link will expire in 15&nbsp;minutes.
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
<a href="{{ link_url }}" target="_blank"
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
Sign in
</a>
</td></tr></table>
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
<p style="margin:0;font-size:12px;color:#a8a29e;">
If you did not request this email, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Hello,
Click this link to sign in:
{{ link_url }}
This link will expire in 15 minutes.
If you did not request this, you can ignore this email.

View File

@@ -0,0 +1,64 @@
{# 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 %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="New Post"
>
<i class="fa fa-plus mr-1"></i> New Post
</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 %}
{% if g.user and (draft_count or drafts) %}
{% if drafts %}
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
<a
href="{{ drafts_off_href }}"
hx-get="{{ drafts_off_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="Hide Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% else %}
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
<a
href="{{ drafts_on_href }}"
hx-get="{{ drafts_on_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
title="Show Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,80 @@
{% import 'macros/stickers.html' as stick %}
<article class="border-b pb-6 last:border-b-0 relative">
{# ❤️ like button - OUTSIDE the link, aligned with image top #}
{% if g.user %}
<div class="absolute top-20 right-2 z-10 text-6xl md:text-4xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
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">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-2 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</header>
{% if post.feature_image %}
<div class="mb-4">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full object-cover"
>
</div>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.custom_excerpt }}
</p>
{% else %}
{% if post.excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.excerpt }}
</p>
{% endif %}
{% endif %}
</a>
{# Card decorations — via fragments #}
{% if card_widgets_html %}
{% set _card_html = card_widgets_html.get(post.id|string, "") %}
{% if _card_html %}{{ _card_html | safe }}{% endif %}
{% endif %}
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,19 @@
<div class="flex flex-row justify-center gap-3">
{% if post.tags %}
<div class="mt-4 flex items-center gap-2">
<div>in</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/tags.html' %}
</ul>
</div>
{% endif %}
<div></div>
{% if post.authors %}
<div class="mt-4 flex items-center gap-2">
<div>by</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/authors.html' %}
</ul>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,21 @@
{% macro author(author) %}
{% if author %}
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6"></div>
{# optional fallback circle with first letter
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div> #}
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ author.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,32 @@
{# --- AUTHORS LIST STARTS HERE --- #}
{% if post.authors and post.authors|length %}
{% for a in post.authors %}
{% for author in authors if author.slug==a.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_author': author.slug }|qs|host}}"
>
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- AUTHOR LIST ENDS HERE --- #}

View File

@@ -0,0 +1,19 @@
{% macro tag(tag) %}
{% if tag %}
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ tag.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,22 @@
{% macro tag_group(group) %}
{% if group %}
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,17 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# --- TAG LIST STARTS HERE --- #}
{% if post.tags and post.tags|length %}
{% for t in post.tags %}
{% for tag in tags if tag.slug==t.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_tag': tag.slug }|qs|host}}"
>
{{dotag.tag(tag)}}
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- TAG LIST ENDS HERE --- #}

View File

@@ -0,0 +1,59 @@
<article class="relative">
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
{% if post.feature_image %}
<div>
<img
src="{{ post.feature_image }}"
alt=""
class="w-full aspect-video object-cover"
>
</div>
{% endif %}
<div class="p-3 text-center">
<h2 class="text-lg font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-1 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.custom_excerpt }}
</p>
{% elif post.excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.excerpt }}
</p>
{% endif %}
</div>
</a>
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,111 @@
{% for post in posts %}
{% if view == 'tile' %}
{% include "_types/blog/_card_tile.html" %}
{% else %}
{% include "_types/blog/_card.html" %}
{% endif %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -0,0 +1,84 @@
{# Content type tabs: Posts | Pages #}
<div class="flex justify-center gap-1 px-3 pt-3">
{% set posts_href = (url_for('blog.index'))|host %}
{% set pages_href = (url_for('blog.index') ~ '?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 #}
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('blog_view') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('blog_view','tile') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</a>
</div>
{# Cards container - list or grid based on view #}
{% if view == 'tile' %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/blog/_cards.html" %}
</div>
{% else %}
<div class="max-w-full px-3 py-3 space-y-3">
{% include "_types/blog/_cards.html" %}
</div>
{% endif %}
<div class="pb-8"></div>
{% endif %}{# end content_type check #}

View File

@@ -0,0 +1,40 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob_.html' import root_header with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'blog-header-child', '_types/blog/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{# Filter container - blog doesn't have child_summary but still needs this element #}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{# Aside with filters #}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/root/_nav.html' %}
{% include '_types/root/_nav_panel.html' %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

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

View 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 %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% 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='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,79 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
{# --- Edit group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.save', id=group.id) }}"
class="border rounded p-4 bg-white space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Name</label>
<input
type="text" name="name" value="{{ group.name }}" required
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs font-medium text-stone-600 mb-1">Colour</label>
<input
type="text" name="colour" value="{{ group.colour or '' }}" placeholder="#hex"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="w-24">
<label class="block text-xs font-medium text-stone-600 mb-1">Order</label>
<input
type="number" name="sort_order" value="{{ group.sort_order }}"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Feature Image URL</label>
<input
type="text" name="feature_image" value="{{ group.feature_image or '' }}"
placeholder="https://..."
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
{# --- Tag checkboxes --- #}
<div>
<label class="block text-xs font-medium text-stone-600 mb-2">Assign Tags</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2">
{% for tag in all_tags %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer">
<input
type="checkbox" name="tag_ids" value="{{ tag.id }}"
{% if tag.id in assigned_tag_ids %}checked{% endif %}
class="rounded border-stone-300"
>
{% if tag.feature_image %}
<img src="{{ tag.feature_image }}" alt="" class="h-4 w-4 rounded-full object-cover">
{% endif %}
<span>{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="flex gap-3">
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Save
</button>
</div>
</form>
{# --- Delete form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.delete_group', id=group.id) }}"
class="border-t pt-4"
onsubmit="return confirm('Delete this tag group? Tags will not be deleted.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="border rounded px-4 py-2 bg-red-600 text-white text-sm">
Delete Group
</button>
</form>
</div>

View File

@@ -0,0 +1,17 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('tag-groups-header-child', 'tag-groups-edit-child', '_types/blog/admin/tag_groups/_edit_header.html')}}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% 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='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,73 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">
{# --- Create new group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.create') }}" class="border rounded p-4 bg-white space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h3 class="text-sm font-semibold text-stone-700">New Group</h3>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text" name="name" placeholder="Group name" required
class="flex-1 border rounded px-3 py-2 text-sm"
>
<input
type="text" name="colour" placeholder="#colour"
class="w-28 border rounded px-3 py-2 text-sm"
>
<input
type="number" name="sort_order" placeholder="Order" value="0"
class="w-20 border rounded px-3 py-2 text-sm"
>
</div>
<input
type="text" name="feature_image" placeholder="Image URL (optional)"
class="w-full border rounded px-3 py-2 text-sm"
>
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Create
</button>
</form>
{# --- Existing groups list --- #}
{% if groups %}
<ul class="space-y-2">
{% for group in groups %}
<li class="border rounded p-3 bg-white flex items-center gap-3">
{% if group.feature_image %}
<img src="{{ group.feature_image }}" alt="{{ group.name }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">
{% else %}
<div class="h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}">
{{ group.name[:1] }}
</div>
{% endif %}
<div class="flex-1">
<a href="{{ url_for('blog.tag_groups_admin.edit', id=group.id) }}"
class="font-medium text-stone-800 hover:underline">
{{ group.name }}
</a>
<span class="text-xs text-stone-500 ml-2">{{ group.slug }}</span>
</div>
<span class="text-xs text-stone-500">order: {{ group.sort_order }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-stone-500 text-sm">No tag groups yet.</p>
{% endif %}
{# --- Unassigned tags --- #}
{% if unassigned_tags %}
<div class="border-t pt-4">
<h3 class="text-sm font-semibold text-stone-700 mb-2">Unassigned Tags ({{ unassigned_tags|length }})</h3>
<div class="flex flex-wrap gap-2">
{% for tag in unassigned_tags %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded">
{{ tag.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,16 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends '_types/blog/admin/tag_groups/index.html' %}
{% block tag_groups_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_edit_header.html' import header_row with context %}
{{ header_row() }}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_header.html' import header_row with context %}
{{ header_row() }}
<div id="tag-groups-header-child">
{% block tag_groups_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% from 'macros/search.html' import search_desktop %}
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
{% include '_types/blog/_action_buttons.html' %}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
</div>

View File

@@ -0,0 +1,62 @@
{% import '_types/blog/_card/author.html' as doauthor %}
{# Author filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_authors | length == 0) %}
{% set href =
{
'remove_author': selected_authors,
}|qs
|host %}
<a
class="px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any author
</a>
</li>
{% for author in authors %}
<li>
{% set is_on = (selected_authors and (author.slug in selected_authors)) %}
{% set qs = {"remove_author": author.slug, "page":None}|qs if is_on
else {"add_author": author.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{doauthor.author(author)}}
{% if False and author.bio %}
<span class="inline-block flex-1 bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{% if author.bio|length > 50 %}
{{ author.bio[:50] ~ "…" }}
{% else %}
{{ author.bio }}
{% endif %}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,70 @@
{# Tag group filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_groups | length == 0 and selected_tags | length == 0) %}
{% set href =
{
'remove_group': selected_groups,
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Topic
</a>
</li>
{% for group in tag_groups %}
{% if group.post_count > 0 or (selected_groups and group.slug in selected_groups) %}
<li>
{% set is_on = (selected_groups and (group.slug in selected_groups)) %}
{% set qs = {"remove_group": group.slug, "page":None}|qs if is_on
else {"add_group": group.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
<span class="flex-1"></span>
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.post_count }}
</span>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,59 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# Tag filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_tags | length == 0) %}
{% set href =
{
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Tag
</a>
</li>
{% for tag in tags %}
<li>
{% set is_on = (selected_tags and (tag.slug in selected_tags)) %}
{% set qs = {"remove_tag": tag.slug, "page":None}|qs if is_on
else {"add_tag": tag.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{dotag.tag(tag)}}
{% if False and tag.description %}
<span class="flex-1 inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.description }}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='blog-row', oob=oob) %}
<div></div>
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,37 @@
{% extends '_types/root/_index.html' %}
{% block meta %}
{{ super() }}
<script>
(function() {
var p = new URLSearchParams(window.location.search);
if (!p.has('view')
&& window.matchMedia('(min-width: 768px)').matches
&& localStorage.getItem('blog_view') === 'tile') {
p.set('view', 'tile');
window.location.replace(window.location.pathname + '?' + p.toString());
}
})();
</script>
{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% block root_blog_header %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
<div class="md:hidden mx-2 bg-stone-200 rounded">
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
<i class="fa-solid fa-filter"></i>
</span>
<span>
<svg aria-hidden="true" viewBox="0 0 24 24"
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
<path d="M6 9l6 6 6-6" fill="currentColor"/>
</svg>
</span>
</div>

View File

@@ -0,0 +1,14 @@
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
{% include '_types/blog/mobile/_filter/summary/tag_groups.html' %}
{% include '_types/blog/mobile/_filter/summary/authors.html' %}
{% endcall %}
{% include '_types/blog/_action_buttons.html' %}
<div id="filter-details-mobile" style="display:contents">
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
{% endcall %}

View File

@@ -0,0 +1,31 @@
{% if selected_authors and selected_authors|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_authors %}
{% for author in authors %}
{% if st == author.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
<span>
{{author.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,33 @@
{% if selected_groups and selected_groups|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for sg in selected_groups %}
{% for group in tag_groups %}
{% if sg == group.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.name }}
</span>
<span>
{{group.post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,31 @@
{% if selected_tags and selected_tags|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_tags %}
{% for tag in tags %}
{% if st == tag.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.name }}
</span>
<span>
{{tag.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,22 @@
{% extends '_types/root/_index.html' %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[50vh] p-8 text-center">
<div class="text-6xl mb-4">📝</div>
<h1 class="text-2xl font-bold text-stone-800 mb-2">Post Not Found</h1>
<p class="text-stone-600 mb-6">
The post "{{ slug }}" could not be found.
</p>
<a
href="{{ url_for('blog.index')|host }}"
hx-get="{{ url_for('blog.index')|host }}"
hx-target="#main-panel"
hx-select="{{ hx_select }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors"
>
← Back to Blog
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
<div class="p-4 space-y-4 max-w-4xl mx-auto">
<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 %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
</div>
{% if drafts %}
<div class="space-y-3">
{% for draft in drafts %}
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
<a
href="{{ edit_href }}"
hx-boost="false"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-stone-900 truncate">
{{ draft.title or "Untitled" }}
</h3>
{% if draft.excerpt %}
<p class="text-stone-600 text-sm mt-1 line-clamp-2">
{{ draft.excerpt }}
</p>
{% endif %}
{% if draft.updated_at %}
<p class="text-xs text-stone-400 mt-2">
Updated: {{ draft.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 flex-shrink-0">
Draft
</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500 text-center py-8">No drafts yet.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,12 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,259 @@
{# ── Error banner ── #}
{% if save_error %}
<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">
<strong>Save failed:</strong> {{ save_error }}
</div>
{% endif %}
<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" id="lexical-json-input" name="lexical" value="">
<input type="hidden" id="feature-image-input" name="feature_image" value="">
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">
{# ── Feature image ── #}
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
{# Empty state: add link #}
<div id="feature-image-empty">
<button
type="button"
id="feature-image-add-btn"
class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
>+ Add feature image</button>
</div>
{# Filled state: image preview + controls #}
<div id="feature-image-filled" class="relative hidden">
<img
id="feature-image-preview"
src=""
alt=""
class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer"
>
{# Delete button (top-right, visible on hover) #}
<button
type="button"
id="feature-image-delete-btn"
class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white
flex items-center justify-center opacity-0 group-hover:opacity-100
transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
title="Remove feature image"
><i class="fa-solid fa-trash-can"></i></button>
{# Caption input #}
<input
type="text"
id="feature-image-caption"
value=""
placeholder="Add a caption..."
class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none
outline-none placeholder:text-stone-300 focus:text-stone-700"
>
</div>
{# Upload spinner overlay #}
<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">
<i class="fa-solid fa-spinner fa-spin"></i> Uploading...
</div>
{# Hidden file input #}
<input
type="file"
id="feature-image-file"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
class="hidden"
>
</div>
{# ── Title ── #}
<input
type="text"
name="title"
value=""
placeholder="{{ 'Page title...' if is_page else 'Post title...' }}"
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
placeholder:text-stone-300 mb-[8px] leading-tight"
>
{# ── Excerpt ── #}
<textarea
name="custom_excerpt"
rows="1"
placeholder="Add an excerpt..."
class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none
placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
></textarea>
{# ── Editor mount point ── #}
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
{# ── Status + Save footer ── #}
<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
<select
name="status"
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
>
<option value="draft" selected>Draft</option>
<option value="published">Published</option>
</select>
<button
type="submit"
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
hover:bg-stone-800 transition-colors cursor-pointer"
>{{ 'Create Page' if is_page else 'Create Post' }}</button>
</div>
</form>
{# ── Koenig editor assets ── #}
<link rel="stylesheet" href="{{ asset_url('scripts/editor.css') }}">
<style>
/* Koenig CSS uses rem, designed for Ghost Admin's html{font-size:62.5%}.
We apply that via JS (see init() below) so the header bars render at
normal size on first paint. A beforeSwap listener restores the
default when navigating away. */
#lexical-editor { display: flow-root; }
/* Reset floats inside HTML cards to match Ghost Admin behaviour */
#lexical-editor [data-kg-card="html"] * { float: none !important; }
#lexical-editor [data-kg-card="html"] table { width: 100% !important; }
</style>
<script src="{{ asset_url('scripts/editor.js') }}"></script>
<script>
(function() {
/* ── Koenig rem fix: apply 62.5% root font-size for the editor,
restore default when navigating away via HTMX ── */
function applyEditorFontSize() {
document.documentElement.style.fontSize = '62.5%';
document.body.style.fontSize = '1.6rem';
}
function restoreDefaultFontSize() {
document.documentElement.style.fontSize = '';
document.body.style.fontSize = '';
}
applyEditorFontSize();
document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {
if (e.detail.target && e.detail.target.id === 'main-panel') {
restoreDefaultFontSize();
document.body.removeEventListener('htmx:beforeSwap', cleanup);
}
});
function init() {
var csrfToken = document.querySelector('input[name="csrf_token"]').value;
var uploadUrl = '{{ url_for("blog.editor_api.upload_image") }}';
var uploadUrls = {
image: uploadUrl,
media: '{{ url_for("blog.editor_api.upload_media") }}',
file: '{{ url_for("blog.editor_api.upload_file") }}',
};
/* ── Feature image upload / delete / replace ── */
var fileInput = document.getElementById('feature-image-file');
var addBtn = document.getElementById('feature-image-add-btn');
var deleteBtn = document.getElementById('feature-image-delete-btn');
var preview = document.getElementById('feature-image-preview');
var emptyState = document.getElementById('feature-image-empty');
var filledState = document.getElementById('feature-image-filled');
var hiddenUrl = document.getElementById('feature-image-input');
var hiddenCaption = document.getElementById('feature-image-caption-input');
var captionInput = document.getElementById('feature-image-caption');
var uploading = document.getElementById('feature-image-uploading');
function showFilled(url) {
preview.src = url;
hiddenUrl.value = url;
emptyState.classList.add('hidden');
filledState.classList.remove('hidden');
uploading.classList.add('hidden');
}
function showEmpty() {
preview.src = '';
hiddenUrl.value = '';
hiddenCaption.value = '';
captionInput.value = '';
emptyState.classList.remove('hidden');
filledState.classList.add('hidden');
uploading.classList.add('hidden');
}
function uploadFile(file) {
emptyState.classList.add('hidden');
uploading.classList.remove('hidden');
var fd = new FormData();
fd.append('file', file);
fetch(uploadUrl, {
method: 'POST',
body: fd,
headers: { 'X-CSRFToken': csrfToken },
})
.then(function(r) {
if (!r.ok) throw new Error('Upload failed (' + r.status + ')');
return r.json();
})
.then(function(data) {
var url = data.images && data.images[0] && data.images[0].url;
if (url) showFilled(url);
else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }
})
.catch(function(e) {
showEmpty();
alert(e.message);
});
}
addBtn.addEventListener('click', function() { fileInput.click(); });
preview.addEventListener('click', function() { fileInput.click(); });
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
showEmpty();
});
fileInput.addEventListener('change', function() {
if (fileInput.files && fileInput.files[0]) {
uploadFile(fileInput.files[0]);
fileInput.value = '';
}
});
captionInput.addEventListener('input', function() {
hiddenCaption.value = captionInput.value;
});
/* ── Auto-resize excerpt textarea ── */
var excerpt = document.querySelector('textarea[name="custom_excerpt"]');
function autoResize() {
excerpt.style.height = 'auto';
excerpt.style.height = excerpt.scrollHeight + 'px';
}
excerpt.addEventListener('input', autoResize);
autoResize();
/* ── Mount Koenig editor ── */
window.mountEditor('lexical-editor', {
initialJson: null,
csrfToken: csrfToken,
uploadUrls: uploadUrls,
oembedUrl: '{{ url_for("blog.editor_api.oembed_proxy") }}',
unsplashApiKey: '{{ unsplash_api_key or "" }}',
snippetsUrl: '{{ url_for("blog.editor_api.list_snippets") }}',
});
/* ── Ctrl-S / Cmd-S to save ── */
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
document.getElementById('post-new-form').requestSubmit();
}
});
}
/* editor.js loads synchronously on full page loads but asynchronously
when HTMX swaps the content in, so wait for it if needed. */
if (typeof window.mountEditor === 'function') {
init();
} else {
var _t = setInterval(function() {
if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }
}, 50);
}
})();
</script>

View File

@@ -0,0 +1,12 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
<article class="relative">
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends '_types/root/_index.html' %}
{% block meta %}
{% include '_types/post/_meta.html' %}
{% endblock %}
{% block content %}
<article class="relative">
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
{% endblock %}

View File

@@ -0,0 +1,125 @@
<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">
{% if menu_item %}Edit{% else %}Add{% endif %} Menu Item
</h2>
<button
type="button"
onclick="document.getElementById('menu-item-form').innerHTML = ''"
class="text-stone-400 hover:text-stone-600">
<i class="fa fa-times"></i>
</button>
</div>
{# Hidden field for selected post ID - outside form for JS access #}
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.container_id if menu_item else '' }}" />
{# Selected page display #}
{% if menu_item %}
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
{% if menu_item.feature_image %}
<img src="{{ menu_item.feature_image }}"
alt="{{ menu_item.label }}"
class="w-10 h-10 rounded-full object-cover" />
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
{% endif %}
<div class="flex-1">
<div class="font-medium">{{ menu_item.label }}</div>
<div class="text-xs text-stone-500">{{ menu_item.slug }}</div>
</div>
</div>
{% else %}
<div id="selected-page-display" class="mb-3 hidden">
{# Will be populated by JavaScript when page selected #}
</div>
{% endif %}
{# Form for submission #}
<form
{% if menu_item %}
hx-put="{{ url_for('menu_items.update_menu_item_route', item_id=menu_item.id) }}"
{% else %}
hx-post="{{ url_for('menu_items.create_menu_item_route') }}"
{% endif %}
hx-target="#menu-items-list"
hx-swap="innerHTML"
hx-include="#selected-post-id"
hx-on::after-request="if(event.detail.successful) { document.getElementById('menu-item-form').innerHTML = '' }"
class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Form actions #}
<div class="flex gap-2 pb-3 border-b">
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fa fa-save"></i> Save
</button>
<button
type="button"
onclick="document.getElementById('menu-item-form').innerHTML = ''"
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">
Cancel
</button>
</div>
</form>
{# Search section - outside form to prevent interference #}
<div class="mt-4">
<label class="block text-sm font-medium text-stone-700 mb-2">
Select Page
</label>
{# Search input #}
<input
type="text"
placeholder="Search for a page... (or leave blank for all)"
hx-get="{{ url_for('menu_items.search_pages_route') }}"
hx-trigger="keyup changed delay:300ms, focus once"
hx-target="#page-search-results"
hx-swap="innerHTML"
name="q"
id="page-search-input"
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
{# Search results container #}
<div id="page-search-results" class="mt-2">
{# Search results will appear here #}
</div>
</div>
</div>
<script>
// Handle page selection
document.addEventListener('click', function(e) {
const pageOption = e.target.closest('[data-page-id]');
if (pageOption) {
const postId = pageOption.dataset.pageId;
const postTitle = pageOption.dataset.pageTitle;
const postSlug = pageOption.dataset.pageSlug;
const postImage = pageOption.dataset.pageImage;
// Update hidden field
document.getElementById('selected-post-id').value = postId;
// Update display
const display = document.getElementById('selected-page-display');
display.innerHTML = `
<div class="p-3 bg-stone-50 rounded flex items-center gap-3">
${postImage ?
`<img src="${postImage}" alt="${postTitle}" class="w-10 h-10 rounded-full object-cover" />` :
`<div class="w-10 h-10 rounded-full bg-stone-200"></div>`
}
<div class="flex-1">
<div class="font-medium">${postTitle}</div>
<div class="text-xs text-stone-500">${postSlug}</div>
</div>
</div>
`;
display.classList.remove('hidden');
// Clear search results
document.getElementById('page-search-results').innerHTML = '';
}
});
</script>

View File

@@ -0,0 +1,68 @@
<div class="bg-white rounded-lg shadow">
{% if menu_items %}
<div class="divide-y">
{% for item in menu_items %}
<div class="flex items-center gap-4 p-4 hover:bg-stone-50 transition">
{# Drag handle #}
<div class="text-stone-400 cursor-move">
<i class="fa fa-grip-vertical"></i>
</div>
{# Page image #}
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
{# Page title #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ item.label }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.slug }}</div>
</div>
{# Sort order #}
<div class="text-sm text-stone-500">
Order: {{ item.sort_order }}
</div>
{# Actions #}
<div class="flex gap-2 flex-shrink-0">
<button
type="button"
hx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
hx-target="#menu-item-form"
hx-swap="innerHTML"
class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded">
<i class="fa fa-edit"></i> Edit
</button>
<button
type="button"
data-confirm
data-confirm-title="Delete menu item?"
data-confirm-text="Remove {{ item.label }} from the menu?"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}"
hx-trigger="confirmed"
hx-target="#menu-items-list"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800">
<i class="fa fa-trash"></i> Delete
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center text-stone-400">
<i class="fa fa-inbox text-4xl mb-2"></i>
<p>No menu items yet. Add one to get started!</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,20 @@
<div class="max-w-4xl mx-auto p-6">
<div class="mb-6 flex justify-end items-center">
<button
type="button"
hx-get="{{ url_for('menu_items.new_menu_item') }}"
hx-target="#menu-item-form"
hx-swap="innerHTML"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<i class="fa fa-plus"></i> Add Menu Item
</button>
</div>
{# Form container #}
<div id="menu-item-form" class="mb-6"></div>
{# Menu items list #}
<div id="menu-items-list">
{% include '_types/menu_items/_list.html' %}
</div>
</div>

View File

@@ -0,0 +1,31 @@
{% set _app_slugs = {'cart': cart_url('/')} %}
{% set _first_seg = request.path.strip('/').split('/')[0] %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="menu-items-nav-wrapper"
hx-swap-oob="outerHTML">
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
<a
href="{{ _href }}"
{% if item.slug not in _app_slugs %}
hx-get="/{{ item.slug }}/"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
{% endif %}
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
class="{{styles.nav_button}}"
>
{% if item.feature_image %}
<img src="{{ item.feature_image }}"
alt="{{ item.label }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<span>{{ item.label }}</span>
</a>
{% endcall %}
</div>

View File

@@ -0,0 +1,23 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'menu_items-header-child', '_types/menu_items/header/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{#% include '_types/root/settings/_nav.html' %#}
{% endblock %}
{% block content %}
{% include '_types/menu_items/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% if pages %}
<div class="border border-stone-200 rounded-md max-h-64 overflow-y-auto">
{% for post in pages %}
<div
class="flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
data-page-id="{{ post.id }}"
data-page-title="{{ post.title }}"
data-page-slug="{{ post.slug }}"
data-page-image="{{ post.feature_image or '' }}">
{# Page image #}
{% if post.feature_image %}
<img src="{{ post.feature_image }}"
alt="{{ post.title }}"
class="w-10 h-10 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
{# Page info #}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ post.title }}</div>
<div class="text-xs text-stone-500 truncate">{{ post.slug }}</div>
</div>
</div>
{% endfor %}
{# Infinite scroll sentinel #}
{% if has_more %}
<div
hx-get="{{ url_for('menu_items.search_pages_route') }}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-vals='{"q": "{{ query }}", "page": {{ page + 1 }}}'
class="p-3 text-center text-sm text-stone-400">
<i class="fa fa-spinner fa-spin"></i> Loading more...
</div>
{% endif %}
</div>
{% elif query %}
<div class="p-3 text-center text-stone-400 border border-stone-200 rounded-md">
No pages found matching "{{ query }}"
</div>
{% endif %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% 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='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/menu_items/header/_header.html' import header_row with context %}
{{ header_row() }}
<div id="menu_items-header-child">
{% block menu_items_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/menu_items/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More