feat: initialize blog app with blueprints and templates
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Extract blog-specific code from the coop monolith into a standalone
repository. Includes auth, blog, post, admin, menu_items, snippets
blueprints, associated templates, Dockerfile (APP_MODULE=app:app),
entrypoint, and Gitea CI workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:15:56 +00:00
commit 8f7a15186c
128 changed files with 9246 additions and 0 deletions

63
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,63 @@
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: registry.rose-ash.com:5000
IMAGE: blog
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
apt-get update && apt-get install -y --no-install-recommends openssh-client
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Pull latest code on server
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/blog
git fetch origin main
git reset --hard origin/main
"
- name: Build and push image
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/blog
docker build --build-arg CACHEBUST=\$(date +%s) -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} .
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
"
- name: Deploy stack
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/blog
source .env
docker stack deploy -c docker-compose.yml blog
echo 'Waiting for services to update...'
sleep 10
docker stack services blog
"

9
.gitignore vendored Normal file
View File

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

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
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 requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .
# ---------- Runtime setup ----------
COPY 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
README.md Normal file
View File

@@ -0,0 +1,60 @@
# Blog App
Blog and content management application for the Rose Ash cooperative platform.
## Overview
This is the **blog** service extracted from the Rose Ash (Suma Browser) monolith.
It handles:
- **Blog**: Ghost CMS integration for browsing, creating, and editing posts
- **Auth**: Magic link authentication and user account management
- **Admin/Settings**: Administrative interface and settings management
- **Menu Items**: Navigation menu item management
- **Snippets**: Reusable content snippet management
- **Internal API**: Server-to-server endpoints for cross-app data sharing
## Tech Stack
- **Quart** (async Flask) with HTMX
- **SQLAlchemy 2.0** (async) with PostgreSQL
- **Redis** for page caching
- **Ghost CMS** for blog content
## Running
```bash
# Set environment variables (see .env.example)
export APP_MODULE=app:app
# Run migrations
alembic upgrade head
# Start the server
hypercorn app:app --bind 0.0.0.0:8000
```
## Docker
```bash
docker build -t blog .
docker run -p 8000:8000 --env-file .env blog
```
## Directory Structure
```
app.py # Application factory and entry point
bp/ # Blueprints
auth/ # Authentication (magic links, account)
blog/ # Blog listing, Ghost CMS integration
post/ # Individual post viewing and admin
admin/ # Settings admin interface
menu_items/ # Navigation menu management
snippets/ # Content snippet management
coop_api.py # Internal API endpoints
templates/ # Jinja2 templates
_types/ # Feature-specific templates
entrypoint.sh # Docker entrypoint (migrations + server start)
Dockerfile # Container build definition
```

105
app.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
from quart import g, request
from sqlalchemy import select
from shared.factory import create_base_app
from config import config
from models import KV
from suma_browser.app.bp import (
register_auth_bp,
register_blog_bp,
register_admin,
register_menu_items,
register_snippets,
register_coop_api,
)
async def coop_context() -> dict:
"""
Coop app context processor.
- menu_items: direct DB query (coop owns this data)
- cart_count/cart_total: fetched from cart internal API
"""
from shared.context import base_context
from suma_browser.app.bp.menu_items.services.menu_items import get_all_menu_items
from shared.internal_api import get as api_get
ctx = await base_context()
# Coop owns menu_items — query directly
ctx["menu_items"] = await get_all_menu_items(g.s)
# Cart data from cart app API
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
if cart_data:
ctx["cart_count"] = cart_data.get("count", 0)
ctx["cart_total"] = cart_data.get("total", 0)
else:
ctx["cart_count"] = 0
ctx["cart_total"] = 0
return ctx
def create_app() -> "Quart":
app = create_base_app("coop", context_fn=coop_context)
# --- blueprints ---
app.register_blueprint(register_auth_bp())
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())
# Internal API (server-to-server, CSRF-exempt)
app.register_blueprint(register_coop_api())
# --- 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()

67
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 suma_browser.app.redis_cacher import clear_all_cache
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
from 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

313
bp/auth/routes.py Normal file
View File

@@ -0,0 +1,313 @@
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from quart import (
Blueprint,
request,
render_template,
make_response,
redirect,
url_for,
session as qsession,
g,
current_app,
)
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from ..blog.ghost.ghost_sync import (
sync_member_to_ghost,
)
from db.session import get_session
from models import User, MagicLink, UserNewsletter
from models.ghost_membership_entities import GhostNewsletter
from config import config
from utils import host_url
from shared.urls import coop_url
from sqlalchemy.orm import selectinload
from suma_browser.app.redis_cacher import clear_cache
from shared.cart_identity import current_cart_identity
from shared.internal_api import post as api_post
from .services import pop_login_redirect_target, store_login_redirect_target
from .services.auth_operations import (
get_app_host,
get_app_root,
send_magic_email,
load_user_by_id,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
oob = {
"oob_extends": "oob_elements.html",
"extends": "_types/root/_index.html",
"parent_id": "root-header-child",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"parent_header": "_types/root/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html"
}
def register(url_prefix="/auth"):
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
@auth_bp.before_request
def route():
pass
SESSION_USER_KEY = "uid"
@auth_bp.context_processor
def context():
return {
"oob": oob,
}
# NOTE: load_current_user moved to shared/user_loader.py
# and registered in shared/factory.py as an app-level before_request
@auth_bp.get("/login/")
async def login_form():
store_login_redirect_target()
if g.get("user"):
return redirect(coop_url("/"))
return await render_template("_types/auth/login.html")
@auth_bp.get("/account/")
async def account():
from suma_browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(host_url(url_for("auth.login_form")))
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX
# For now, render full template for both HTMX and normal requests
# 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/auth/index.html")
else:
# HTMX request: main panel + OOB elements
html = await render_template(
"_types/auth/_oob_elements.html",
)
return await make_response(html)
@auth_bp.get("/newsletters/")
async def newsletters():
from suma_browser.app.utils.htmx import is_htmx_request
if not g.get("user"):
return redirect(host_url(url_for("auth.login_form")))
# Fetch all newsletters, sorted alphabetically
result = await g.s.execute(
select(GhostNewsletter).order_by(GhostNewsletter.name)
)
all_newsletters = result.scalars().all()
# Fetch user's subscription states
sub_result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
)
)
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
# Build list with subscription state for template
newsletter_list = []
for nl in all_newsletters:
un = user_subs.get(nl.id)
newsletter_list.append({
"newsletter": nl,
"un": un,
"subscribed": un.subscribed if un else False,
})
nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"}
if not is_htmx_request():
html = await render_template(
"_types/auth/index.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
else:
html = await render_template(
"_types/auth/_oob_elements.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
return await make_response(html)
@auth_bp.post("/start/")
@clear_cache(tag_scope="user", clear_user=True)
async def start_login():
# 1. Get and validate email
form = await request.form
email_input = form.get("email") or ""
is_valid, email = validate_email(email_input)
if not is_valid:
return (
await render_template(
"_types/auth/login.html",
error="Please enter a valid email address.",
email=email_input,
),
400,
)
# 2. Create/find user and issue magic link token
user = await find_or_create_user(g.s, email)
token, expires = await create_magic_link(g.s, user.id)
g.s.commit()
# 3. Build the magic link URL
magic_url = host_url(url_for("auth.magic", token=token))
# 4. Try sending the email
email_error = None
try:
await send_magic_email(email, magic_url)
except Exception as e:
print("EMAIL SEND FAILED:", repr(e))
email_error = (
"We couldn't send the email automatically. "
"Please try again in a moment."
)
# 5. Render "check your email" page
return await render_template(
"_types/auth/check_email.html",
email=email,
email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
now = datetime.now(timezone.utc)
user_id: int | None = None
# ---- Step 1: Validate & consume magic link ----
try:
async with get_session() as s:
async with s.begin():
user, error = await validate_magic_link(s, token)
if error:
return (
await render_template(
"_types/auth/login.html",
error=error,
),
400,
)
user_id = user.id
# Try to ensure Ghost membership inside this txn
try:
if not user.ghost_id:
await sync_member_to_ghost(s, user.id)
except Exception:
current_app.logger.exception(
"[auth] Ghost upsert failed for user_id=%s", user.id
)
raise
except Exception:
# Any DB/Ghost error → generic failure
return (
await render_template(
"_types/auth/login.html",
error="Could not sign you in right now. Please try again.",
),
502,
)
# At this point:
# - magic link is consumed
# - user_id is valid
# - Ghost membership is ensured or we already returned 502
assert user_id is not None # for type checkers / sanity
# Figure out any anonymous session we want to adopt
ident = current_cart_identity()
anon_session_id = ident.get("session_id")
# ---- Step 3: best-effort local update (non-fatal) ----
try:
async with get_session() as s:
async with s.begin():
u2 = await s.get(User, user_id)
if u2:
u2.last_login_at = now
# s.begin() will commit on successful exit
except SQLAlchemyError:
current_app.logger.exception(
"[auth] non-fatal DB update after Ghost upsert for user_id=%s", user_id
)
# Adopt cart + calendar entries via cart app internal API
if anon_session_id:
await api_post(
"cart",
"/internal/cart/adopt",
json={"user_id": user_id, "session_id": anon_session_id},
)
# ---- Finalize login ----
qsession[SESSION_USER_KEY] = user_id
# Redirect back to where they came from, if we stored it.
redirect_url = pop_login_redirect_target()
return redirect(redirect_url, 303)
@auth_bp.post("/newsletter/<int:newsletter_id>/toggle/")
async def toggle_newsletter(newsletter_id: int):
if not g.get("user"):
return "", 401
result = await g.s.execute(
select(UserNewsletter).where(
UserNewsletter.user_id == g.user.id,
UserNewsletter.newsletter_id == newsletter_id,
)
)
un = result.scalar_one_or_none()
if un:
un.subscribed = not un.subscribed
else:
un = UserNewsletter(
user_id=g.user.id,
newsletter_id=newsletter_id,
subscribed=True,
)
g.s.add(un)
await g.s.flush()
return await render_template(
"_types/auth/_newsletter_toggle.html",
un=un,
)
@auth_bp.post("/logout/")
async def logout():
qsession.pop(SESSION_USER_KEY, None)
return redirect(coop_url("/"))
return auth_bp

View File

@@ -0,0 +1,24 @@
from .login_redirect import pop_login_redirect_target, store_login_redirect_target
from .auth_operations import (
get_app_host,
get_app_root,
send_magic_email,
load_user_by_id,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
__all__ = [
"pop_login_redirect_target",
"store_login_redirect_target",
"get_app_host",
"get_app_root",
"send_magic_email",
"load_user_by_id",
"find_or_create_user",
"create_magic_link",
"validate_magic_link",
"validate_email",
]

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from quart import current_app, request, g
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models import User, MagicLink, UserNewsletter
from config import config
def get_app_host() -> str:
"""Get the application host URL from config or environment."""
host = (
config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000"
).rstrip("/")
return host
def get_app_root() -> str:
"""Get the application root path from request context."""
root = (g.root).rstrip("/")
return root
async def send_magic_email(to_email: str, link_url: str) -> None:
"""
Send magic link email via SMTP if configured, otherwise log to console.
Args:
to_email: Recipient email address
link_url: Magic link URL to include in email
Raises:
Exception: If SMTP sending fails
"""
host = os.getenv("SMTP_HOST")
port = int(os.getenv("SMTP_PORT") or "587")
username = os.getenv("SMTP_USER")
password = os.getenv("SMTP_PASS")
mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com"
subject = "Your sign-in link"
body = f"""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.
"""
if not host or not username or not password:
# Fallback: log to console
current_app.logger.warning(
"SMTP not configured. Printing magic link to console for %s: %s",
to_email,
link_url,
)
print(f"[DEV] Magic link for {to_email}: {link_url}")
return
# Lazy import to avoid dependency unless used
import aiosmtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = mail_from
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(body)
is_secure = port == 465 # implicit TLS if true
if is_secure:
# implicit TLS (like nodemailer secure: true)
smtp = aiosmtplib.SMTP(
hostname=host,
port=port,
use_tls=True,
username=username,
password=password,
)
else:
# plain connect then STARTTLS (like secure: false but with TLS upgrade)
smtp = aiosmtplib.SMTP(
hostname=host,
port=port,
start_tls=True,
username=username,
password=password,
)
async with smtp:
await smtp.send_message(msg)
async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""
Load a user by ID with labels and newsletters eagerly loaded.
Args:
session: Database session
user_id: User ID to load
Returns:
User object or None if not found
"""
stmt = (
select(User)
.options(
selectinload(User.labels),
selectinload(User.user_newsletters).selectinload(
UserNewsletter.newsletter
),
)
.where(User.id == user_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def find_or_create_user(session: AsyncSession, email: str) -> User:
"""
Find existing user by email or create a new one.
Args:
session: Database session
email: User email address (should be lowercase and trimmed)
Returns:
User object (either existing or newly created)
"""
result = await session.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None:
user = User(email=email)
session.add(user)
await session.flush() # Ensure user.id exists
return user
async def create_magic_link(
session: AsyncSession,
user_id: int,
purpose: str = "signin",
expires_minutes: int = 15,
) -> Tuple[str, datetime]:
"""
Create a new magic link token for authentication.
Args:
session: Database session
user_id: User ID to create link for
purpose: Purpose of the link (default: "signin")
expires_minutes: Minutes until expiration (default: 15)
Returns:
Tuple of (token, expires_at)
"""
token = secrets.token_urlsafe(32)
expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
ml = MagicLink(
token=token,
user_id=user_id,
purpose=purpose,
expires_at=expires,
ip=request.headers.get("x-forwarded-for", request.remote_addr),
user_agent=request.headers.get("user-agent"),
)
session.add(ml)
return token, expires
async def validate_magic_link(
session: AsyncSession,
token: str,
) -> Tuple[Optional[User], Optional[str]]:
"""
Validate and consume a magic link token.
Args:
session: Database session (should be in a transaction)
token: Magic link token to validate
Returns:
Tuple of (user, error_message)
- If user is None, error_message contains the reason
- If user is returned, the link was valid and has been consumed
"""
now = datetime.now(timezone.utc)
ml = await session.scalar(
select(MagicLink)
.where(MagicLink.token == token)
.with_for_update()
)
if not ml or ml.purpose != "signin":
return None, "Invalid or expired link."
if ml.used_at or ml.expires_at < now:
return None, "This link has expired. Please request a new one."
user = await session.get(User, ml.user_id)
if not user:
return None, "User not found."
# Mark link as used
ml.used_at = now
return user, None
def validate_email(email: str) -> Tuple[bool, str]:
"""
Validate email address format.
Args:
email: Email address to validate
Returns:
Tuple of (is_valid, normalized_email)
"""
email = email.strip().lower()
if not email or "@" not in email:
return False, email
return True, email

View File

@@ -0,0 +1,45 @@
from urllib.parse import urlparse
from quart import session
from shared.urls import coop_url
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
def store_login_redirect_target() -> None:
from quart import request
target = request.args.get("next")
if not target:
ref = request.referrer or ""
try:
parsed = urlparse(ref)
target = parsed.path or ""
except Exception:
target = ""
if not target:
return
# Accept both relative paths and absolute URLs (cross-app redirects)
if target.startswith("http://") or target.startswith("https://"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
elif target.startswith("/") and not target.startswith("//"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
def pop_login_redirect_target() -> str:
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
if not path or not isinstance(path, str):
return coop_url("/auth/")
# Absolute URL: return as-is (cross-app redirect)
if path.startswith("http://") or path.startswith("https://"):
return path
# Relative path: must start with / and not //
if path.startswith("/") and not path.startswith("//"):
return coop_url(path)
return coop_url("/auth/")

7
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

173
bp/blog/admin/routes.py Normal 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 suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
from suma_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
bp/blog/filters/qs.py Normal file
View File

@@ -0,0 +1,120 @@
from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import (
KEEP, _norm, make_filter_set, build_qs,
)
from suma_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

256
bp/blog/ghost/editor_api.py Normal file
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 suma_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,170 @@
"""
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) -> dict | None:
"""Fetch a single post by Ghost ID, including lexical source."""
url = (
f"{GHOST_ADMIN_API_URL}/posts/{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()["posts"][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 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,
) -> 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
payload = {"posts": [post_body]}
url = f"{GHOST_ADMIN_API_URL}/posts/{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()["posts"][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,
**kwargs,
) -> dict:
"""Update Ghost post settings (slug, tags, SEO, social, etc.).
Only non-None keyword args are included in the PUT payload.
Accepts any key from ``_SETTINGS_FIELDS``.
"""
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 = {"posts": [post_body]}
url = f"{GHOST_ADMIN_API_URL}/posts/{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()["posts"][0]

1069
bp/blog/ghost/ghost_sync.py Normal file

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

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

@@ -0,0 +1,559 @@
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 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 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]

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

@@ -0,0 +1,203 @@
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 db.session import get_session
from .filters.qs import makeqs_factory, decode
from .services.posts_data import posts_data
from suma_browser.app.redis_cacher import cache_page, invalidate_tag_cache
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.authz import require_admin
from 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():
q = decode()
# 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,
"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("/drafts/")
async def drafts():
return redirect(host_url(url_for("blog.home")) + "?drafts=1")
return blogs_bp

View File

@@ -0,0 +1,137 @@
from ..ghost_db import DBClient # adjust import path
from sqlalchemy import select
from models.ghost_content import PostLike
from models.calendars import CalendarEntry, CalendarEntryPost
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 associated entries for each post
# Get all confirmed entries associated with these posts
from sqlalchemy.orm import selectinload
entries_result = await session.execute(
select(CalendarEntry, CalendarEntryPost.post_id)
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
.options(selectinload(CalendarEntry.calendar)) # Eagerly load calendar
.where(
CalendarEntryPost.post_id.in_(post_ids),
CalendarEntryPost.deleted_at.is_(None),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed"
)
.order_by(CalendarEntry.start_at.asc())
)
# Group entries by post_id
entries_by_post = {}
for entry, post_id in entries_result:
if post_id not in entries_by_post:
entries_by_post[post_id] = []
entries_by_post[post_id].append(entry)
# Add associated_entries to each post
for post in posts:
post["associated_entries"] = entries_by_post.get(post["id"], [])
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,
}

120
bp/blog/web_hooks/routes.py Normal file
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 suma_browser.app.redis_cacher import clear_cache
from suma_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)

83
bp/coop_api.py Normal file
View File

@@ -0,0 +1,83 @@
"""
Internal JSON API for the coop app.
These endpoints are called by other apps (market, cart) over HTTP
to fetch Ghost CMS content and menu items without importing blog services.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.menu_item import MenuItem
from suma_browser.app.csrf import csrf_exempt
def register() -> Blueprint:
bp = Blueprint("coop_api", __name__, url_prefix="/internal")
@bp.get("/menu-items")
@csrf_exempt
async def menu_items():
"""
Return all active menu items as lightweight JSON.
Called by market and cart apps to render the nav.
"""
result = await g.s.execute(
select(MenuItem)
.where(MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
)
items = result.scalars().all()
return jsonify(
[
{
"id": mi.id,
"post": {
"title": mi.post.title if mi.post else None,
"slug": mi.post.slug if mi.post else None,
"feature_image": mi.post.feature_image if mi.post else None,
},
}
for mi in items
]
)
@bp.get("/post/<slug>")
@csrf_exempt
async def post_by_slug(slug: str):
"""
Return a Ghost post's key fields by slug.
Called by market app for the landing page.
"""
from suma_browser.app.bp.blog.ghost_db import DBClient
client = DBClient(g.s)
posts = await client.posts_by_slug(slug, include_drafts=False)
if not posts:
return jsonify(None), 404
post, original_post = posts[0]
return jsonify(
{
"post": {
"id": post.get("id"),
"title": post.get("title"),
"html": post.get("html"),
"custom_excerpt": post.get("custom_excerpt"),
"feature_image": post.get("feature_image"),
"slug": post.get("slug"),
},
"original_post": {
"id": getattr(original_post, "id", None),
"title": getattr(original_post, "title", None),
},
}
)
return bp

View File

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

213
bp/menu_items/routes.py Normal file
View File

@@ -0,0 +1,213 @@
from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, jsonify, g
from suma_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 suma_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,204 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from models.menu_item import MenuItem
from models.ghost_content import Post
class MenuItemError(ValueError):
"""Base error for menu item service operations."""
async def get_all_menu_items(session: AsyncSession) -> list[MenuItem]:
"""
Get all menu items (excluding deleted), ordered by sort_order.
Eagerly loads the post relationship.
"""
from sqlalchemy.orm import selectinload
result = await session.execute(
select(MenuItem)
.where(MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
.order_by(MenuItem.sort_order.asc(), MenuItem.id.asc())
)
return list(result.scalars().all())
async def get_menu_item_by_id(session: AsyncSession, item_id: int) -> MenuItem | None:
"""Get a menu item by ID (excluding deleted)."""
from sqlalchemy.orm import selectinload
result = await session.execute(
select(MenuItem)
.where(MenuItem.id == item_id, MenuItem.deleted_at.is_(None))
.options(selectinload(MenuItem.post))
)
return result.scalar_one_or_none()
async def create_menu_item(
session: AsyncSession,
post_id: int,
sort_order: int | None = None
) -> MenuItem:
"""
Create a new menu item.
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(MenuItem.sort_order))
.where(MenuItem.deleted_at.is_(None))
)
sort_order = (max_order or 0) + 1
# Check for duplicate (same post, not deleted)
existing = await session.scalar(
select(MenuItem).where(
MenuItem.post_id == post_id,
MenuItem.deleted_at.is_(None)
)
)
if existing:
raise MenuItemError(f"Menu item for this page already exists.")
menu_item = MenuItem(
post_id=post_id,
sort_order=sort_order
)
session.add(menu_item)
await session.flush()
# Reload with post relationship
await session.refresh(menu_item, ["post"])
return menu_item
async def update_menu_item(
session: AsyncSession,
item_id: int,
post_id: int | None = None,
sort_order: int | None = None
) -> MenuItem:
"""Update an existing menu item."""
menu_item = await get_menu_item_by_id(session, item_id)
if not menu_item:
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 post, different menu item)
existing = await session.scalar(
select(MenuItem).where(
MenuItem.post_id == post_id,
MenuItem.id != item_id,
MenuItem.deleted_at.is_(None)
)
)
if existing:
raise MenuItemError(f"Menu item for this page already exists.")
menu_item.post_id = post_id
if sort_order is not None:
menu_item.sort_order = sort_order
await session.flush()
await session.refresh(menu_item, ["post"])
return menu_item
async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
"""Soft delete a menu item."""
menu_item = await get_menu_item_by_id(session, item_id)
if not menu_item:
return False
menu_item.deleted_at = func.now()
await session.flush()
return True
async def reorder_menu_items(
session: AsyncSession,
item_ids: list[int]
) -> list[MenuItem]:
"""
Reorder menu items by providing a list of IDs in desired order.
Updates sort_order for each item.
"""
items = []
for index, item_id in enumerate(item_ids):
menu_item = await get_menu_item_by_id(session, item_id)
if menu_item:
menu_item.sort_order = index
items.append(menu_item)
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).
"""
# Build search filter
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

462
bp/post/admin/routes.py Normal file
View File

@@ -0,0 +1,462 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
Blueprint,
g,
request,
redirect,
url_for,
)
from suma_browser.app.authz import require_admin, require_post_author
from suma_browser.app.utils.htmx import is_htmx_request
from utils import host_url
def register():
bp = Blueprint("admin", __name__, url_prefix='/admin')
@bp.get("/")
@require_admin
async def admin(slug: str):
from suma_browser.app.utils.htmx import is_htmx_request
# 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")
else:
# HTMX request: main panel + OOB elements
html = await render_template("_types/post/admin/_oob_elements.html")
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 models.calendars import Calendar
from sqlalchemy import select
from datetime import datetime, timezone
from quart import request
import calendar as pycalendar
from ...calendar.services.calendar_view import parse_int_arg, add_months, build_calendar_weeks
from ...calendar.services import get_visible_entries_for_period
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)
session_id = qsession.get("calendar_sid")
visible = await get_visible_entries_for_period(
sess=g.s,
calendar_id=calendar_obj.id,
period_start=period_start,
period_end=period_end,
user=user,
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=visible.merged_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 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 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.post_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"]
ghost_post = await get_post_for_edit(ghost_id)
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
from suma_browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
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,
**kwargs,
)
# Sync to local DB
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 models.ghost_membership_entities import GhostNewsletter
from sqlalchemy import select as sa_select
ghost_id = g.post_data["post"]["ghost_id"]
ghost_post = await get_post_for_edit(ghost_id)
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
from suma_browser.app.redis_cacher import invalidate_tag_cache
ghost_id = g.post_data["post"]["ghost_id"]
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)
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)
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,
)
# 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,
**email_kwargs,
)
# Sync to local DB
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)
return bp

158
bp/post/routes.py Normal file
View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from quart import (
render_template,
make_response,
g,
Blueprint,
abort,
url_for,
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from models.calendars import Calendar
from sqlalchemy import select
from suma_browser.app.redis_cacher import cache_page, clear_cache
from .admin.routes import register as register_admin
from config import config
from suma_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 .services.entry_associations import get_associated_entries
db_post_id = (g.post_data.get("post") or {}).get("id") # <-- integer
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == db_post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
# Fetch associated entries for nav display
associated_entries = await get_associated_entries(g.s, db_post_id)
return {
**p_data,
"base_title": f"{config()['title']} {p_data['post']['title']}",
"calendars": calendars,
"associated_entries": associated_entries,
}
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 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("/entries/")
async def get_entries(slug: str):
"""Get paginated associated entries for infinite scroll in nav"""
from .services.entry_associations import get_associated_entries
from quart import request
page = int(request.args.get("page", 1))
post_id = g.post_data["post"]["id"]
result = await get_associated_entries(g.s, post_id, page=page, per_page=10)
html = await render_template(
"_types/post/_entry_items.html",
entries=result["entries"],
page=result["page"],
has_more=result["has_more"],
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,143 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost, Calendar
from models.ghost_content import Post
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).
"""
# Check if entry exists (don't filter by deleted_at - allow associating with any entry)
entry = await session.scalar(
select(CalendarEntry).where(CalendarEntry.id == entry_id)
)
if not entry:
return False, f"Calendar entry {entry_id} not found in database"
# Check if post exists
post = await session.scalar(
select(Post).where(Post.id == post_id)
)
if not post:
return False, "Post not found"
# Check if association already exists
existing = await session.scalar(
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
if existing:
# Remove association (soft delete)
existing.deleted_at = func.now()
await session.flush()
return False, None
else:
# Create association
association = CalendarEntryPost(
entry_id=entry_id,
post_id=post_id
)
session.add(association)
await session.flush()
return True, 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.
"""
result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
return set(result.scalars().all())
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, total_count, and has_more.
"""
# Get all associated entry IDs
entry_ids_result = await session.execute(
select(CalendarEntryPost.entry_id)
.where(
CalendarEntryPost.post_id == post_id,
CalendarEntryPost.deleted_at.is_(None)
)
)
entry_ids = set(entry_ids_result.scalars().all())
if not entry_ids:
return {
"entries": [],
"total_count": 0,
"has_more": False,
"page": page,
}
# Get total count
from sqlalchemy import func
total_count = len(entry_ids)
# Get paginated entries ordered by start_at desc
# Only include confirmed entries
offset = (page - 1) * per_page
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.id.in_(entry_ids),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed" # Only confirmed entries in nav
)
.order_by(CalendarEntry.start_at.desc())
.limit(per_page)
.offset(offset)
)
entries = result.scalars().all()
# Recalculate total_count based on confirmed entries only
total_count = len(entries) + offset # Rough estimate
if len(entries) < per_page:
total_count = offset + len(entries)
# Load calendar relationship for each entry
for entry in entries:
await session.refresh(entry, ["calendar"])
if entry.calendar:
await session.refresh(entry.calendar, ["post"])
has_more = len(entries) == per_page # More accurate check
return {
"entries": entries,
"total_count": total_count,
"has_more": has_more,
"page": page,
}

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

3
bp/snippets/__init__.py Normal file
View File

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

107
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 suma_browser.app.authz import require_login
from suma_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

31
entrypoint.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/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 (uses alembic.ini/env.py to resolve the DB URL)
echo "Running Alembic migrations..."
alembic upgrade head
# 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
# APP_MODULE can be overridden per-service (e.g. apps.market.app:app)
echo "Starting Hypercorn (${APP_MODULE:-suma_browser.app.app:app})..."
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-suma_browser.app.app:app}" --bind 0.0.0.0:${PORT:-8000}

View File

@@ -0,0 +1,49 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">
{% if error %}
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
{# Account header #}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
{% if g.user %}
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
{% if g.user.name %}
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
{% endif %}
{% endif %}
</div>
<form action="{{ url_for('auth.logout')|host }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
>
<i class="fa-solid fa-right-from-bracket text-xs"></i>
Sign out
</button>
</form>
</div>
{# Labels #}
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
{% if labels %}
<div>
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
<div class="flex flex-wrap gap-2">
{% for label in labels %}
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
{{ label.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,7 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('auth.newsletters'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% call links.link(cart_url('/orders/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
orders
{% endcall %}

View File

@@ -0,0 +1,17 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=un.newsletter_id) }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
></span>
</button>
</div>

View File

@@ -0,0 +1,46 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>
{% if newsletter_list %}
<div class="divide-y divide-stone-100">
{% for item in newsletter_list %}
<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ item.newsletter.name }}</p>
{% if item.newsletter.description %}
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
{% endif %}
</div>
<div class="ml-4 flex-shrink-0">
{% if item.un %}
{% with un=item.un %}
{% include "_types/auth/_newsletter_toggle.html" %}
{% endwith %}
{% else %}
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=item.newsletter.id) }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
role="switch"
aria-checked="false"
>
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No newsletters available.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,29 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/auth/_nav.html' %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
If an account exists for
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
youll receive a link to sign in. It expires in 15 minutes.
</p>
{% if email_error %}
<div
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
role="alert"
>
<span class="font-medium">Heads up:</span>
<span>{{ email_error }}</span>
</div>
{% endif %}
<p class="mt-6 text-sm">
<a
href="{{ url_for('auth.login_form')|host }}"
class="text-stone-600 dark:text-stone-300 hover:underline"
>
← Back
</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(url_for('auth.account'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include "_types/auth/_nav.html" %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends "_types/root/_index.html" %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include "_types/auth/_nav.html" %}
{% endblock %}
{% block content %}
{% include '_types/auth/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends oob.extends %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row(oob.child_id, oob.header) %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include oob.nav %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Enter your email and well email you a one-time sign-in link.
</p>
{% if error %}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
<form
method="post" action="{{ url_for('auth.start_login')|host }}"
class="mt-6 space-y-5"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Email
</label>
<input
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
autocomplete="email"
inputmode="email"
>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
>
Send link
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{# New Post + 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>
{% 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,115 @@
{% 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>
{# Associated Entries - Scrollable list #}
{% if post.associated_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in post.associated_entries %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
calendar_slug=entry.calendar.slug,
year=entry.start_at.year,
month=entry.start_at.month,
day=entry.start_at.day,
entry_id=entry.id) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{% 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,48 @@
{# 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>

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,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 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(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.home')|host }}"
hx-get="{{ url_for('blog.home')|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="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 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,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.post_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.post.feature_image %}
<img src="{{ menu_item.post.feature_image }}"
alt="{{ menu_item.post.title }}"
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.post.title }}</div>
<div class="text-xs text-stone-500">{{ menu_item.post.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.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
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.post.title }}</div>
<div class="text-xs text-stone-500 truncate">{{ item.post.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.post.title }} 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,29 @@
{% set _app_slugs = {'market': market_url('/'), 'cart': cart_url('/')} %}
<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.post.slug, coop_url('/' + item.post.slug + '/')) %}
<a
href="{{ _href }}"
{% if item.post.slug not in _app_slugs %}
hx-get="/{{ item.post.slug }}/"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
{% endif %}
class="{{styles.nav_button}}"
>
{% if item.post.feature_image %}
<img src="{{ item.post.feature_image }}"
alt="{{ item.post.title }}"
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.post.title }}</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 %}

View File

@@ -0,0 +1,24 @@
<div id="associated-entries-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .entries-nav-arrow
else
add .hidden to .entries-nav-arrow
end">
<div class="flex flex-col sm:flex-row gap-1">
{% include '_types/post/_entry_items.html' with context %}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -0,0 +1,43 @@
{# Get entries from either direct variable or associated_entries dict #}
{% set entry_list = entries if entries is defined else (associated_entries.entries if associated_entries is defined else []) %}
{% set current_page = page if page is defined else (associated_entries.page if associated_entries is defined else 1) %}
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
{% for entry in entry_list %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=entry.calendar.post.slug,
calendar_slug=entry.calendar.slug,
year=entry.start_at.year,
month=entry.start_at.month,
day=entry.start_at.day,
entry_id=entry.id) }}"
class="{{styles.nav_button_less_pad}}"
>
{% if entry.calendar.post.feature_image %}
<img src="{{ entry.calendar.post.feature_image }}"
alt="{{ entry.calendar.post.title }}"
class="w-8 h-8 rounded object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600 truncate">
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
</a>
{% endfor %}
{# Load more entries one at a time until container is full #}
{% if has_more_entries %}
<div id="entries-load-sentinel-{{ current_page }}"
hx-get="{{ url_for('blog.post.get_entries', slug=post.slug, page=current_page + 1) }}"
hx-trigger="intersect once"
hx-swap="beforebegin"
_="on htmx:afterRequest trigger scroll on #associated-entries-container"
class="flex-shrink-0 w-1">
</div>
{% endif %}

View File

@@ -0,0 +1,62 @@
{# Main panel fragment for HTMX navigation - post article content #}
<article class="relative">
{# ❤️ like button - always visible in top right of article #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
{% 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 %}
{# Draft indicator + edit link #}
{% if post.status == "draft" %}
<div class="flex items-center justify-center gap-2 mb-3">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
{% set is_admin = (g.get("rights") or {}).get("admin") %}
{% if is_admin or (g.user and post.user_id == g.user.id) %}
{% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
<a
href="{{ edit_href }}"
hx-get="{{ edit_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
>
<i class="fa fa-pencil mr-1"></i> Edit
</a>
{% endif %}
</div>
{% endif %}
{% if post.custom_excerpt %}
<div class="w-full text-center italic text-3xl p-2">
{{post.custom_excerpt|safe}}
</div>
{% endif %}
<div class="hidden md:block">
{% include '_types/blog/_card/at_bar.html' %}
</div>
{% if post.feature_image %}
<div class="mb-3 flex justify-center">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full md:w-3/4 object-cover"
>
</div>
{% endif %}
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
<div class="pb-8"></div>

View File

@@ -0,0 +1,124 @@
{# --- social/meta_post.html --- #}
{# Context expected:
site, post, request
#}
{# Visibility → robots #}
{% set is_public = (post.visibility == 'public') %}
{% set is_published = (post.status == 'published') %}
{% set robots_here = 'index,follow' if (is_public and is_published and not post.email_only) else 'noindex,nofollow' %}
{# Compute canonical early so both this file and base can use it #}
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
{% set _post_path = request.path if request else ('/posts/' ~ (post.slug or post.uuid)) %}
{% set canonical = post.canonical_url or (_site_url ~ _post_path if _site_url else (request.url if request else None)) %}
{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #}
{% set robots_override = robots_here %}
{% include 'social/meta_base.html' %}
{# ---- Titles / descriptions ---- #}
{% set og_title = post.og_title or base_title %}
{% set tw_title = post.twitter_title or base_title %}
{# Description best-effort, trimmed #}
{% set desc_source = post.meta_description
or post.og_description
or post.twitter_description
or post.custom_excerpt
or post.excerpt
or (post.plaintext if post.plaintext else (post.html|striptags if post.html else '')) %}
{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %}
{# Image priority #}
{% set image_url = post.og_image
or post.twitter_image
or post.feature_image
or (site().default_image if site and site().default_image else None) %}
{# Dates #}
{% set published_iso = post.published_at.isoformat() if post.published_at else None %}
{% set updated_iso = post.updated_at.isoformat() if post.updated_at
else (post.created_at.isoformat() if post.created_at else None) %}
{# Authors / tags #}
{% set primary_author = post.primary_author %}
{% set authors = post.authors or ([primary_author] if primary_author else []) %}
{% set tag_names = (post.tags or []) | map(attribute='name') | list %}
{% set is_article = not post.is_page %}
<title>{{ base_title }}</title>
<meta name="description" content="{{ description }}">
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
{# ---- Open Graph ---- #}
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
<meta property="og:type" content="{{ 'article' if is_article else 'website' }}">
<meta property="og:title" content="{{ og_title }}">
<meta property="og:description" content="{{ description }}">
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
{% if image_url %}<meta property="og:image" content="{{ image_url }}">{% endif %}
{% if is_article and published_iso %}<meta property="article:published_time" content="{{ published_iso }}">{% endif %}
{% if is_article and updated_iso %}
<meta property="article:modified_time" content="{{ updated_iso }}">
<meta property="og:updatd_time" content="{{ updated_iso }}">
{% endif %}
{% if is_article and post.primary_tag and post.primary_tag.name %}
<meta property="article:section" content="{{ post.primary_tag.name }}">
{% endif %}
{% if is_article %}
{% for t in tag_names %}
<meta property="article:tag" content="{{ t }}">
{% endfor %}
{% endif %}
{# ---- Twitter ---- #}
<meta name="twitter:card" content="{{ 'summary_large_image' if image_url else 'summary' }}">
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
{% if primary_author and primary_author.twitter %}
<meta name="twitter:creator" content="@{{ primary_author.twitter | replace('@','') }}">
{% endif %}
<meta name="twitter:title" content="{{ tw_title }}">
<meta name="twitter:description" content="{{ description }}">
{% if image_url %}<meta name="twitter:image" content="{{ image_url }}">{% endif %}
{# ---- JSON-LD author value (no list comprehensions) ---- #}
{% if authors and authors|length == 1 %}
{% set author_value = {"@type": "Person", "name": authors[0].name} %}
{% elif authors %}
{% set ns = namespace(arr=[]) %}
{% for a in authors %}
{% set _ = ns.arr.append({"@type": "Person", "name": a.name}) %}
{% endfor %}
{% set author_value = ns.arr %}
{% else %}
{% set author_value = none %}
{% endif %}
{# ---- JSON-LD using combine for optionals ---- #}
{% set jsonld = {
"@context": "https://schema.org",
"@type": "BlogPosting" if is_article else "WebPage",
"mainEntityOfPage": canonical,
"headline": base_title,
"description": description,
"image": image_url,
"datePublished": published_iso,
"author": author_value,
"publisher": {
"@type": "Organization",
"name": site().title if site and site().title else "",
"logo": {"@type": "ImageObject", "url": site().logo if site and site().logo else image_url}
}
} %}
{% if updated_iso %}
{% set jsonld = jsonld | combine({"dateModified": updated_iso}) %}
{% endif %}
{% if tag_names %}
{% set jsonld = jsonld | combine({"keywords": tag_names | join(", ")}) %}
{% endif %}
<script type="application/ld+json">
{{ jsonld | tojson }}
</script>

View File

@@ -0,0 +1,16 @@
{% import 'macros/links.html' as links %}
{# Associated Entries and Calendars - vertical on mobile, horizontal with arrows on desktop #}
{% if (associated_entries and associated_entries.entries) or calendars %}
<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="entries-calendars-nav-wrapper">
{% include '_types/post/admin/_nav_entries.html' %}
</div>
{% endif %}
{# Admin link #}
{% if post and has_access('blog.post.admin.admin') %}
{% import 'macros/links.html' as links %}
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i>
{% endcall %}
{% endif %}

View File

@@ -0,0 +1,36 @@
{% 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_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 %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header(id='root-header-child', oob=True) %}
{% call header() %}
{% from '_types/post/header/_header.html' import header_row with context %}
{{header_row()}}
<div id="post-header-child">
</div>
{% endcall %}
{% endcall %}
{# Mobile menu #}
{% block mobile_menu %}
{% include '_types/post/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/post/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,50 @@
<div id="associated-entries-list" class="border rounded-lg p-4 bg-white">
<h3 class="text-lg font-semibold mb-4">Associated Entries</h3>
{% if associated_entry_ids %}
<div class="space-y-1">
{% for calendar in all_calendars %}
{% for entry in calendar.entries %}
{% if entry.id in associated_entry_ids and entry.deleted_at is none %}
<button
type="button"
class="w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
data-confirm
data-confirm-title="Remove entry?"
data-confirm-text="This will remove {{ entry.name }} from this post"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
>
<div class="flex items-center justify-between gap-3">
{% if calendar.post.feature_image %}
<img src="{{ calendar.post.feature_image }}"
alt="{{ calendar.post.title }}"
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 %}
<div class="flex-1">
<div class="font-medium text-sm">{{ entry.name }}</div>
<div class="text-xs text-stone-600 mt-1">
{{ calendar.name }} • {{ entry.start_at.strftime('%A, %B %d, %Y at %H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
<i class="fa fa-times-circle text-green-600 text-lg flex-shrink-0"></i>
</div>
</button>
{% endif %}
{% endfor %}
{% endfor %}
</div>
{% else %}
<div class="text-sm text-stone-400">No entries associated yet. Browse calendars below to add entries.</div>
{% endif %}
</div>

View File

@@ -0,0 +1,88 @@
<div id="calendar-view-{{ calendar.id }}"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=year, month=month) }}"
hx-trigger="entryToggled from:body"
hx-swap="outerHTML">
{# Month/year navigation #}
<header class="flex items-center justify-center mb-4">
<nav class="flex items-center gap-2 text-xl">
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&laquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ prev_month_year }}&month={{ prev_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=prev_month_year, month=prev_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&lsaquo;</a>
<div class="px-3 font-medium">{{ month_name }} {{ year }}</div>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_month_year }}&month={{ next_month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_month_year, month=next_month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&rsaquo;</a>
<a class="px-2 py-1 hover:bg-stone-100 rounded" href="?calendar_id={{ calendar.id }}&year={{ next_year }}&month={{ month }}" hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id, year=next_year, month=month) }}" hx-target="#calendar-view-{{ calendar.id }}" hx-swap="outerHTML">&raquo;</a>
</nav>
</header>
{# Calendar grid #}
<div class="rounded border bg-white">
<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">
{% for wd in weekday_names %}
<div class="py-2">{{ wd }}</div>
{% endfor %}
</div>
<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">
{% for week in weeks %}
{% for day in week %}
<div class="min-h-20 bg-white px-2 py-2 text-xs {% if not day.in_month %} bg-stone-50 text-stone-400{% endif %}">
<div class="font-medium mb-1">{{ day.date.day }}</div>
{# Entries for this day #}
<div class="space-y-0.5">
{% for e in month_entries %}
{% if e.start_at.date() == day.date and e.deleted_at is none %}
{% if e.id in associated_entry_ids %}
{# Associated entry - show with delete button #}
<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">
<span class="truncate flex-1">{{ e.name }}</span>
<button
type="button"
class="flex-shrink-0 hover:text-red-600"
data-confirm
data-confirm-title="Remove entry?"
data-confirm-text="Remove {{ e.name }} from this post?"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
>
<i class="fa fa-times"></i>
</button>
</div>
{% else %}
{# Non-associated entry - clickable to add #}
<button
type="button"
class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"
data-confirm
data-confirm-title="Add entry?"
data-confirm-text="Add {{ e.name }} to this post?"
data-confirm-icon="question"
data-confirm-confirm-text="Yes, add it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=e.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
>
<span class="truncate block">{{ e.name }}</span>
</button>
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</div>

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