feat: initialize blog app with blueprints and templates
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
63
.gitea/workflows/ci.yml
Normal file
63
.gitea/workflows/ci.yml
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal 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
60
README.md
Normal 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
105
app.py
Normal 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
67
bp/admin/routes.py
Normal 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
313
bp/auth/routes.py
Normal 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
|
||||||
24
bp/auth/services/__init__.py
Normal file
24
bp/auth/services/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
239
bp/auth/services/auth_operations.py
Normal file
239
bp/auth/services/auth_operations.py
Normal 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
|
||||||
45
bp/auth/services/login_redirect.py
Normal file
45
bp/auth/services/login_redirect.py
Normal 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
7
bp/blog/__init__.py
Normal 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
|
||||||
0
bp/blog/admin/__init__.py
Normal file
0
bp/blog/admin/__init__.py
Normal file
173
bp/blog/admin/routes.py
Normal file
173
bp/blog/admin/routes.py
Normal 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
120
bp/blog/filters/qs.py
Normal 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
256
bp/blog/ghost/editor_api.py
Normal 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})
|
||||||
46
bp/blog/ghost/ghost_admin_token.py
Normal file
46
bp/blog/ghost/ghost_admin_token.py
Normal 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
|
||||||
170
bp/blog/ghost/ghost_posts.py
Normal file
170
bp/blog/ghost/ghost_posts.py
Normal 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
1069
bp/blog/ghost/ghost_sync.py
Normal file
File diff suppressed because it is too large
Load Diff
668
bp/blog/ghost/lexical_renderer.py
Normal file
668
bp/blog/ghost/lexical_renderer.py
Normal 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×</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>'
|
||||||
|
)
|
||||||
86
bp/blog/ghost/lexical_validator.py
Normal file
86
bp/blog/ghost/lexical_validator.py
Normal 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
559
bp/blog/ghost_db.py
Normal 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
203
bp/blog/routes.py
Normal 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
|
||||||
137
bp/blog/services/posts_data.py
Normal file
137
bp/blog/services/posts_data.py
Normal 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
120
bp/blog/web_hooks/routes.py
Normal 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
83
bp/coop_api.py
Normal 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
|
||||||
3
bp/menu_items/__init__.py
Normal file
3
bp/menu_items/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .routes import register
|
||||||
|
|
||||||
|
__all__ = ["register"]
|
||||||
213
bp/menu_items/routes.py
Normal file
213
bp/menu_items/routes.py
Normal 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
|
||||||
204
bp/menu_items/services/menu_items.py
Normal file
204
bp/menu_items/services/menu_items.py
Normal 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
462
bp/post/admin/routes.py
Normal 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
158
bp/post/routes.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
143
bp/post/services/entry_associations.py
Normal file
143
bp/post/services/entry_associations.py
Normal 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,
|
||||||
|
}
|
||||||
42
bp/post/services/post_data.py
Normal file
42
bp/post/services/post_data.py
Normal 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,
|
||||||
|
}
|
||||||
58
bp/post/services/post_operations.py
Normal file
58
bp/post/services/post_operations.py
Normal 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
3
bp/snippets/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .routes import register
|
||||||
|
|
||||||
|
__all__ = ["register"]
|
||||||
107
bp/snippets/routes.py
Normal file
107
bp/snippets/routes.py
Normal 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
31
entrypoint.sh
Normal 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}
|
||||||
49
templates/_types/auth/_main_panel.html
Normal file
49
templates/_types/auth/_main_panel.html
Normal 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>
|
||||||
7
templates/_types/auth/_nav.html
Normal file
7
templates/_types/auth/_nav.html
Normal 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 %}
|
||||||
17
templates/_types/auth/_newsletter_toggle.html
Normal file
17
templates/_types/auth/_newsletter_toggle.html
Normal 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>
|
||||||
46
templates/_types/auth/_newsletters_panel.html
Normal file
46
templates/_types/auth/_newsletters_panel.html
Normal 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>
|
||||||
29
templates/_types/auth/_oob_elements.html
Normal file
29
templates/_types/auth/_oob_elements.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
33
templates/_types/auth/check_email.html
Normal file
33
templates/_types/auth/check_email.html
Normal 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>,
|
||||||
|
you’ll 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 %}
|
||||||
12
templates/_types/auth/header/_header.html
Normal file
12
templates/_types/auth/header/_header.html
Normal 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 %}
|
||||||
18
templates/_types/auth/index copy.html
Normal file
18
templates/_types/auth/index copy.html
Normal 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 %}
|
||||||
18
templates/_types/auth/index.html
Normal file
18
templates/_types/auth/index.html
Normal 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 %}
|
||||||
46
templates/_types/auth/login.html
Normal file
46
templates/_types/auth/login.html
Normal 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 we’ll 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 %}
|
||||||
51
templates/_types/blog/_action_buttons.html
Normal file
51
templates/_types/blog/_action_buttons.html
Normal 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>
|
||||||
115
templates/_types/blog/_card.html
Normal file
115
templates/_types/blog/_card.html
Normal 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>
|
||||||
19
templates/_types/blog/_card/at_bar.html
Normal file
19
templates/_types/blog/_card/at_bar.html
Normal 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>
|
||||||
21
templates/_types/blog/_card/author.html
Normal file
21
templates/_types/blog/_card/author.html
Normal 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 %}
|
||||||
32
templates/_types/blog/_card/authors.html
Normal file
32
templates/_types/blog/_card/authors.html
Normal 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 --- #}
|
||||||
19
templates/_types/blog/_card/tag.html
Normal file
19
templates/_types/blog/_card/tag.html
Normal 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 %}
|
||||||
22
templates/_types/blog/_card/tag_group.html
Normal file
22
templates/_types/blog/_card/tag_group.html
Normal 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 %}
|
||||||
17
templates/_types/blog/_card/tags.html
Normal file
17
templates/_types/blog/_card/tags.html
Normal 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 --- #}
|
||||||
59
templates/_types/blog/_card_tile.html
Normal file
59
templates/_types/blog/_card_tile.html
Normal 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>
|
||||||
111
templates/_types/blog/_cards.html
Normal file
111
templates/_types/blog/_cards.html
Normal 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 %}
|
||||||
|
|
||||||
48
templates/_types/blog/_main_panel.html
Normal file
48
templates/_types/blog/_main_panel.html
Normal 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>
|
||||||
40
templates/_types/blog/_oob_elements.html
Normal file
40
templates/_types/blog/_oob_elements.html
Normal 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 %}
|
||||||
9
templates/_types/blog/admin/tag_groups/_edit_header.html
Normal file
9
templates/_types/blog/admin/tag_groups/_edit_header.html
Normal 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 %}
|
||||||
79
templates/_types/blog/admin/tag_groups/_edit_main_panel.html
Normal file
79
templates/_types/blog/admin/tag_groups/_edit_main_panel.html
Normal 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>
|
||||||
17
templates/_types/blog/admin/tag_groups/_edit_oob.html
Normal file
17
templates/_types/blog/admin/tag_groups/_edit_oob.html
Normal 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 %}
|
||||||
9
templates/_types/blog/admin/tag_groups/_header.html
Normal file
9
templates/_types/blog/admin/tag_groups/_header.html
Normal 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 %}
|
||||||
73
templates/_types/blog/admin/tag_groups/_main_panel.html
Normal file
73
templates/_types/blog/admin/tag_groups/_main_panel.html
Normal 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>
|
||||||
16
templates/_types/blog/admin/tag_groups/_oob_elements.html
Normal file
16
templates/_types/blog/admin/tag_groups/_oob_elements.html
Normal 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 %}
|
||||||
13
templates/_types/blog/admin/tag_groups/edit.html
Normal file
13
templates/_types/blog/admin/tag_groups/edit.html
Normal 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 %}
|
||||||
20
templates/_types/blog/admin/tag_groups/index.html
Normal file
20
templates/_types/blog/admin/tag_groups/index.html
Normal 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 %}
|
||||||
19
templates/_types/blog/desktop/menu.html
Normal file
19
templates/_types/blog/desktop/menu.html
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
62
templates/_types/blog/desktop/menu/authors.html
Normal file
62
templates/_types/blog/desktop/menu/authors.html
Normal 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>
|
||||||
|
|
||||||
70
templates/_types/blog/desktop/menu/tag_groups.html
Normal file
70
templates/_types/blog/desktop/menu/tag_groups.html
Normal 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>
|
||||||
59
templates/_types/blog/desktop/menu/tags.html
Normal file
59
templates/_types/blog/desktop/menu/tags.html
Normal 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>
|
||||||
|
|
||||||
7
templates/_types/blog/header/_header.html
Normal file
7
templates/_types/blog/header/_header.html
Normal 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 %}
|
||||||
37
templates/_types/blog/index.html
Normal file
37
templates/_types/blog/index.html
Normal 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 %}
|
||||||
13
templates/_types/blog/mobile/_filter/_hamburger.html
Normal file
13
templates/_types/blog/mobile/_filter/_hamburger.html
Normal 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>
|
||||||
14
templates/_types/blog/mobile/_filter/summary.html
Normal file
14
templates/_types/blog/mobile/_filter/summary.html
Normal 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 %}
|
||||||
|
|
||||||
31
templates/_types/blog/mobile/_filter/summary/authors.html
Normal file
31
templates/_types/blog/mobile/_filter/summary/authors.html
Normal 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 %}
|
||||||
33
templates/_types/blog/mobile/_filter/summary/tag_groups.html
Normal file
33
templates/_types/blog/mobile/_filter/summary/tag_groups.html
Normal 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 %}
|
||||||
31
templates/_types/blog/mobile/_filter/summary/tags.html
Normal file
31
templates/_types/blog/mobile/_filter/summary/tags.html
Normal 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 %}
|
||||||
22
templates/_types/blog/not_found.html
Normal file
22
templates/_types/blog/not_found.html
Normal 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 %}
|
||||||
55
templates/_types/blog_drafts/_main_panel.html
Normal file
55
templates/_types/blog_drafts/_main_panel.html
Normal 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>
|
||||||
12
templates/_types/blog_drafts/_oob_elements.html
Normal file
12
templates/_types/blog_drafts/_oob_elements.html
Normal 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 %}
|
||||||
11
templates/_types/blog_drafts/index.html
Normal file
11
templates/_types/blog_drafts/index.html
Normal 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 %}
|
||||||
259
templates/_types/blog_new/_main_panel.html
Normal file
259
templates/_types/blog_new/_main_panel.html
Normal 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>
|
||||||
12
templates/_types/blog_new/_oob_elements.html
Normal file
12
templates/_types/blog_new/_oob_elements.html
Normal 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 %}
|
||||||
11
templates/_types/blog_new/index.html
Normal file
11
templates/_types/blog_new/index.html
Normal 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 %}
|
||||||
125
templates/_types/menu_items/_form.html
Normal file
125
templates/_types/menu_items/_form.html
Normal 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>
|
||||||
68
templates/_types/menu_items/_list.html
Normal file
68
templates/_types/menu_items/_list.html
Normal 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>
|
||||||
20
templates/_types/menu_items/_main_panel.html
Normal file
20
templates/_types/menu_items/_main_panel.html
Normal 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>
|
||||||
29
templates/_types/menu_items/_nav_oob.html
Normal file
29
templates/_types/menu_items/_nav_oob.html
Normal 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>
|
||||||
23
templates/_types/menu_items/_oob_elements.html
Normal file
23
templates/_types/menu_items/_oob_elements.html
Normal 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 %}
|
||||||
|
|
||||||
44
templates/_types/menu_items/_page_search_results.html
Normal file
44
templates/_types/menu_items/_page_search_results.html
Normal 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 %}
|
||||||
9
templates/_types/menu_items/header/_header.html
Normal file
9
templates/_types/menu_items/header/_header.html
Normal 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 %}
|
||||||
20
templates/_types/menu_items/index.html
Normal file
20
templates/_types/menu_items/index.html
Normal 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 %}
|
||||||
24
templates/_types/post/_entry_container.html
Normal file
24
templates/_types/post/_entry_container.html
Normal 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>
|
||||||
43
templates/_types/post/_entry_items.html
Normal file
43
templates/_types/post/_entry_items.html
Normal 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 %}
|
||||||
62
templates/_types/post/_main_panel.html
Normal file
62
templates/_types/post/_main_panel.html
Normal 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>
|
||||||
124
templates/_types/post/_meta.html
Normal file
124
templates/_types/post/_meta.html
Normal 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>
|
||||||
16
templates/_types/post/_nav.html
Normal file
16
templates/_types/post/_nav.html
Normal 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 %}
|
||||||
36
templates/_types/post/_oob_elements.html
Normal file
36
templates/_types/post/_oob_elements.html
Normal 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 %}
|
||||||
50
templates/_types/post/admin/_associated_entries.html
Normal file
50
templates/_types/post/admin/_associated_entries.html
Normal 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>
|
||||||
88
templates/_types/post/admin/_calendar_view.html
Normal file
88
templates/_types/post/admin/_calendar_view.html
Normal 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">«</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">‹</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">›</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">»</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
Reference in New Issue
Block a user