Compare commits
56 Commits
sexpressio
...
cssx
| Author | SHA1 | Date | |
|---|---|---|---|
| 544892edd9 | |||
| c243d17eeb | |||
| 5b4cacaf19 | |||
| a8c0741f54 | |||
| 0af07f9f2e | |||
| 222738546a | |||
| 4098c32878 | |||
| 3bd4f4b661 | |||
| 5dd1161816 | |||
| 002cc49f2c | |||
| e6b0849ce3 | |||
| 8024fa5b13 | |||
| ea18a402d6 | |||
| e4e43177a8 | |||
| 8445c36270 | |||
| 5578923242 | |||
| 9754b892d6 | |||
| ab75e505a8 | |||
| 13bcf755f6 | |||
| 3d55145e5f | |||
| 8b2785ccb0 | |||
| 03196c3ad0 | |||
| 815c5285d5 | |||
| ed30f88f05 | |||
| 8aedbc9e62 | |||
| 8ceb9aee62 | |||
| 4668c30890 | |||
| 39f61eddd6 | |||
| 5436dfe76c | |||
| 4ede0368dc | |||
| a8e06e87fb | |||
| 588d240ddc | |||
| aa5c251a45 | |||
| 7ccb463a8b | |||
| 341fc4cf28 | |||
| 1a5969202e | |||
| 3bc5de126d | |||
| 1447122a0c | |||
| ab45e21c7c | |||
| c0d369eb8e | |||
| 755313bd29 | |||
| 01a67029f0 | |||
| b54f7b4b56 | |||
| 5ede32e21c | |||
| 7aea1f1be9 | |||
| 0ef4a93a92 | |||
| 48696498ef | |||
| b7d95a8b4e | |||
| e7d5c6734b | |||
| e4a6d2dfc8 | |||
| 0a5562243b | |||
| 2b41aaa6ce | |||
| cfe66e5342 | |||
| 382d1b7c7a | |||
| a580a53328 | |||
| 0f9af31ffe |
@@ -58,13 +58,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for app in blog market cart events federation account relations likes orders test; do
|
# Map compose service name to source directory
|
||||||
|
app_dir() {
|
||||||
|
case \"\$1\" in
|
||||||
|
sx_docs) echo \"sx\" ;;
|
||||||
|
*) echo \"\$1\" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
for app in blog market cart events federation account relations likes orders test sx_docs; do
|
||||||
|
dir=\$(app_dir \"\$app\")
|
||||||
IMAGE_EXISTS=\$(docker image ls -q ${{ env.REGISTRY }}/\$app:latest 2>/dev/null)
|
IMAGE_EXISTS=\$(docker image ls -q ${{ env.REGISTRY }}/\$app:latest 2>/dev/null)
|
||||||
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$app/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
|
if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q \"^\$dir/\" || [ -z \"\$IMAGE_EXISTS\" ]; then
|
||||||
echo \"Building \$app...\"
|
echo \"Building \$app...\"
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg CACHEBUST=\$(date +%s) \
|
--build-arg CACHEBUST=\$(date +%s) \
|
||||||
-f \$app/Dockerfile \
|
-f \$dir/Dockerfile \
|
||||||
-t ${{ env.REGISTRY }}/\$app:latest \
|
-t ${{ env.REGISTRY }}/\$app:latest \
|
||||||
-t ${{ env.REGISTRY }}/\$app:${{ github.sha }} \
|
-t ${{ env.REGISTRY }}/\$app:${{ github.sha }} \
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ app_urls:
|
|||||||
events: "https://events.rose-ash.com"
|
events: "https://events.rose-ash.com"
|
||||||
federation: "https://federation.rose-ash.com"
|
federation: "https://federation.rose-ash.com"
|
||||||
account: "https://account.rose-ash.com"
|
account: "https://account.rose-ash.com"
|
||||||
|
sx: "https://sx.rose-ash.com"
|
||||||
|
test: "https://test.rose-ash.com"
|
||||||
|
orders: "https://orders.rose-ash.com"
|
||||||
cache:
|
cache:
|
||||||
fs_root: /app/_snapshot # <- absolute path to your snapshot dir
|
fs_root: /app/_snapshot # <- absolute path to your snapshot dir
|
||||||
categories:
|
categories:
|
||||||
|
|||||||
43
account/alembic/versions/0003_add_user_profile_fields.py
Normal file
43
account/alembic/versions/0003_add_user_profile_fields.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Add author profile fields to users table.
|
||||||
|
|
||||||
|
Merges Ghost Author profile data into User — bio, profile_image, cover_image,
|
||||||
|
website, location, facebook, twitter, slug, is_admin.
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002_hash_oauth_tokens
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "acct_0003"
|
||||||
|
down_revision = "acct_0002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("users", sa.Column("slug", sa.String(191), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("bio", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("profile_image", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("cover_image", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("website", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("location", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("facebook", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("twitter", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column(
|
||||||
|
"is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false"),
|
||||||
|
))
|
||||||
|
op.create_index("ix_users_slug", "users", ["slug"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_users_slug")
|
||||||
|
op.drop_column("users", "is_admin")
|
||||||
|
op.drop_column("users", "twitter")
|
||||||
|
op.drop_column("users", "facebook")
|
||||||
|
op.drop_column("users", "location")
|
||||||
|
op.drop_column("users", "website")
|
||||||
|
op.drop_column("users", "cover_image")
|
||||||
|
op.drop_column("users", "profile_image")
|
||||||
|
op.drop_column("users", "bio")
|
||||||
|
op.drop_column("users", "slug")
|
||||||
@@ -72,9 +72,19 @@ def create_app() -> "Quart":
|
|||||||
app.jinja_loader,
|
app.jinja_loader,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Setup defpage routes
|
||||||
|
import sx.sx_components # noqa: F811 — ensure components loaded
|
||||||
|
from sxc.pages import setup_account_pages
|
||||||
|
setup_account_pages()
|
||||||
|
|
||||||
# --- blueprints ---
|
# --- blueprints ---
|
||||||
app.register_blueprint(register_auth_bp())
|
app.register_blueprint(register_auth_bp())
|
||||||
app.register_blueprint(register_account_bp())
|
|
||||||
|
account_bp = register_account_bp()
|
||||||
|
from shared.sx.pages import mount_pages
|
||||||
|
mount_pages(account_bp, "account")
|
||||||
|
app.register_blueprint(account_bp)
|
||||||
|
|
||||||
app.register_blueprint(register_fragments())
|
app.register_blueprint(register_fragments())
|
||||||
|
|
||||||
from bp.actions.routes import register as register_actions
|
from bp.actions.routes import register as register_actions
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"""Account pages blueprint.
|
"""Account pages blueprint.
|
||||||
|
|
||||||
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
|
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
|
||||||
Mounted at root /.
|
Mounted at root /. GET page handlers replaced by defpage.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from quart import (
|
from quart import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
request,
|
request,
|
||||||
make_response,
|
|
||||||
redirect,
|
redirect,
|
||||||
g,
|
g,
|
||||||
)
|
)
|
||||||
@@ -20,85 +19,62 @@ from shared.infrastructure.urls import login_url
|
|||||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
|
|
||||||
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="/"):
|
def register(url_prefix="/"):
|
||||||
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
|
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
|
||||||
|
|
||||||
@account_bp.context_processor
|
@account_bp.before_request
|
||||||
async def context():
|
async def _prepare_page_data():
|
||||||
|
"""Fetch account_nav fragments and load data for defpage routes."""
|
||||||
|
# Fetch account nav items for layout (was in context_processor)
|
||||||
events_nav, cart_nav, artdag_nav = await fetch_fragments([
|
events_nav, cart_nav, artdag_nav = await fetch_fragments([
|
||||||
("events", "account-nav-item", {}),
|
("events", "account-nav-item", {}),
|
||||||
("cart", "account-nav-item", {}),
|
("cart", "account-nav-item", {}),
|
||||||
("artdag", "nav-item", {}),
|
("artdag", "nav-item", {}),
|
||||||
], required=False)
|
], required=False)
|
||||||
return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav}
|
g.account_nav = events_nav + cart_nav + artdag_nav
|
||||||
|
|
||||||
@account_bp.get("/")
|
if request.method != "GET":
|
||||||
async def account():
|
return
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_account_page, render_account_oob
|
|
||||||
|
|
||||||
if not g.get("user"):
|
endpoint = request.endpoint or ""
|
||||||
return redirect(login_url("/"))
|
|
||||||
|
|
||||||
ctx = await get_template_context()
|
# Newsletters page — load newsletter data
|
||||||
if not is_htmx_request():
|
if endpoint.endswith("defpage_newsletters"):
|
||||||
html = await render_account_page(ctx)
|
result = await g.s.execute(
|
||||||
return await make_response(html)
|
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
||||||
else:
|
|
||||||
sx_src = await render_account_oob(ctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@account_bp.get("/newsletters/")
|
|
||||||
async def newsletters():
|
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
|
||||||
|
|
||||||
if not g.get("user"):
|
|
||||||
return redirect(login_url("/newsletters/"))
|
|
||||||
|
|
||||||
result = await g.s.execute(
|
|
||||||
select(GhostNewsletter).order_by(GhostNewsletter.name)
|
|
||||||
)
|
|
||||||
all_newsletters = result.scalars().all()
|
|
||||||
|
|
||||||
sub_result = await g.s.execute(
|
|
||||||
select(UserNewsletter).where(
|
|
||||||
UserNewsletter.user_id == g.user.id,
|
|
||||||
)
|
)
|
||||||
)
|
all_newsletters = result.scalars().all()
|
||||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
|
||||||
|
|
||||||
newsletter_list = []
|
sub_result = await g.s.execute(
|
||||||
for nl in all_newsletters:
|
select(UserNewsletter).where(
|
||||||
un = user_subs.get(nl.id)
|
UserNewsletter.user_id == g.user.id,
|
||||||
newsletter_list.append({
|
)
|
||||||
"newsletter": nl,
|
)
|
||||||
"un": un,
|
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||||
"subscribed": un.subscribed if un else False,
|
|
||||||
})
|
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
newsletter_list = []
|
||||||
from sx.sx_components import render_newsletters_page, render_newsletters_oob
|
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,
|
||||||
|
})
|
||||||
|
g.newsletters_data = newsletter_list
|
||||||
|
|
||||||
ctx = await get_template_context()
|
# Fragment page — load fragment from events service
|
||||||
if not is_htmx_request():
|
elif endpoint.endswith("defpage_fragment_page"):
|
||||||
html = await render_newsletters_page(ctx, newsletter_list)
|
slug = request.view_args.get("slug")
|
||||||
return await make_response(html)
|
if slug and g.get("user"):
|
||||||
else:
|
fragment_html = await fetch_fragment(
|
||||||
sx_src = await render_newsletters_oob(ctx, newsletter_list)
|
"events", "account-page",
|
||||||
return sx_response(sx_src)
|
params={"slug": slug, "user_id": str(g.user.id)},
|
||||||
|
)
|
||||||
|
if not fragment_html:
|
||||||
|
from quart import abort
|
||||||
|
abort(404)
|
||||||
|
g.fragment_page_data = fragment_html
|
||||||
|
|
||||||
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
||||||
async def toggle_newsletter(newsletter_id: int):
|
async def toggle_newsletter(newsletter_id: int):
|
||||||
@@ -128,31 +104,4 @@ def register(url_prefix="/"):
|
|||||||
from sx.sx_components import render_newsletter_toggle
|
from sx.sx_components import render_newsletter_toggle
|
||||||
return sx_response(render_newsletter_toggle(un))
|
return sx_response(render_newsletter_toggle(un))
|
||||||
|
|
||||||
# Catch-all for fragment-provided pages — must be last
|
|
||||||
@account_bp.get("/<slug>/")
|
|
||||||
async def fragment_page(slug):
|
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
|
||||||
from quart import abort
|
|
||||||
|
|
||||||
if not g.get("user"):
|
|
||||||
return redirect(login_url(f"/{slug}/"))
|
|
||||||
|
|
||||||
fragment_html = await fetch_fragment(
|
|
||||||
"events", "account-page",
|
|
||||||
params={"slug": slug, "user_id": str(g.user.id)},
|
|
||||||
)
|
|
||||||
if not fragment_html:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_fragment_page, render_fragment_oob
|
|
||||||
|
|
||||||
ctx = await get_template_context()
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_fragment_page(ctx, fragment_html)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_fragment_oob(ctx, fragment_html)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
return account_bp
|
return account_bp
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ from .services import (
|
|||||||
SESSION_USER_KEY = "uid"
|
SESSION_USER_KEY = "uid"
|
||||||
ACCOUNT_SESSION_KEY = "account_sid"
|
ACCOUNT_SESSION_KEY = "account_sid"
|
||||||
|
|
||||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "artdag", "artdag_l2"}
|
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "sx", "artdag", "artdag_l2"}
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix="/auth"):
|
def register(url_prefix="/auth"):
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
by other coop apps via the fragment client.
|
||||||
|
|
||||||
Fragments:
|
All handlers are defined declaratively in .sx files under
|
||||||
auth-menu Desktop + mobile auth menu (sign-in or user link)
|
``account/sx/handlers/`` and dispatched via the sx handler registry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,32 +12,12 @@ from __future__ import annotations
|
|||||||
from quart import Blueprint, Response, request
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# Fragment handlers — return sx source text
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _auth_menu():
|
|
||||||
from shared.infrastructure.urls import account_url
|
|
||||||
from shared.sx.helpers import sx_call
|
|
||||||
|
|
||||||
user_email = request.args.get("email", "")
|
|
||||||
return sx_call("auth-menu",
|
|
||||||
user_email=user_email or None,
|
|
||||||
account_url=account_url(""))
|
|
||||||
|
|
||||||
_handlers = {
|
|
||||||
"auth-menu": _auth_menu,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# Routing
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -45,10 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("account", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
src = await handler()
|
handler_def, "account", args=dict(request.args),
|
||||||
return Response(src, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
|
return Response("", status=200, content_type="text/sx")
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -1,23 +1,5 @@
|
|||||||
;; Auth page components (login, device, check email)
|
;; Auth page components (device auth — account-specific)
|
||||||
|
;; Login and check-email components are shared: see shared/sx/templates/auth.sx
|
||||||
(defcomp ~account-login-error (&key error)
|
|
||||||
(when error
|
|
||||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4"
|
|
||||||
error)))
|
|
||||||
|
|
||||||
(defcomp ~account-login-form (&key error action csrf-token email)
|
|
||||||
(div :class "py-8 max-w-md mx-auto"
|
|
||||||
(h1 :class "text-2xl font-bold mb-6" "Sign in")
|
|
||||||
error
|
|
||||||
(form :method "post" :action action :class "space-y-4"
|
|
||||||
(input :type "hidden" :name "csrf_token" :value csrf-token)
|
|
||||||
(div
|
|
||||||
(label :for "email" :class "block text-sm font-medium mb-1" "Email address")
|
|
||||||
(input :type "email" :name "email" :id "email" :value email :required true :autofocus true
|
|
||||||
:class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))
|
|
||||||
(button :type "submit"
|
|
||||||
:class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
|
||||||
"Send magic link"))))
|
|
||||||
|
|
||||||
(defcomp ~account-device-error (&key error)
|
(defcomp ~account-device-error (&key error)
|
||||||
(when error
|
(when error
|
||||||
@@ -45,14 +27,3 @@
|
|||||||
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
(h1 :class "text-2xl font-bold mb-4" "Device authorized")
|
||||||
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
(p :class "text-stone-600" "You can close this window and return to your terminal.")))
|
||||||
|
|
||||||
(defcomp ~account-check-email-error (&key error)
|
|
||||||
(when error
|
|
||||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4"
|
|
||||||
error)))
|
|
||||||
|
|
||||||
(defcomp ~account-check-email (&key email error)
|
|
||||||
(div :class "py-8 max-w-md mx-auto text-center"
|
|
||||||
(h1 :class "text-2xl font-bold mb-4" "Check your email")
|
|
||||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
|
|
||||||
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
|
|
||||||
error))
|
|
||||||
|
|||||||
@@ -41,8 +41,3 @@
|
|||||||
name)
|
name)
|
||||||
logout)
|
logout)
|
||||||
labels)))
|
labels)))
|
||||||
|
|
||||||
;; Header child wrapper
|
|
||||||
(defcomp ~account-header-child (&key inner)
|
|
||||||
(div :id "root-header-child" :class "flex flex-col w-full items-center"
|
|
||||||
inner))
|
|
||||||
|
|||||||
8
account/sx/handlers/auth-menu.sx
Normal file
8
account/sx/handlers/auth-menu.sx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
;; Account auth-menu fragment handler
|
||||||
|
;;
|
||||||
|
;; Renders the desktop + mobile auth menu (sign-in or user link).
|
||||||
|
|
||||||
|
(defhandler auth-menu (&key email)
|
||||||
|
(~auth-menu
|
||||||
|
:user-email (when email email)
|
||||||
|
:account-url (app-url "account" "")))
|
||||||
@@ -10,12 +10,6 @@
|
|||||||
:class cls :role "switch" :aria-checked checked
|
:class cls :role "switch" :aria-checked checked
|
||||||
(span :class knob-cls))))
|
(span :class knob-cls))))
|
||||||
|
|
||||||
(defcomp ~account-newsletter-toggle-off (&key id url hdrs target)
|
|
||||||
(div :id id :class "flex items-center"
|
|
||||||
(button :sx-post url :sx-headers hdrs :sx-target target :sx-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"))))
|
|
||||||
|
|
||||||
(defcomp ~account-newsletter-item (&key name desc toggle)
|
(defcomp ~account-newsletter-item (&key name desc toggle)
|
||||||
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"
|
(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ from typing import Any
|
|||||||
from shared.sx.jinja_bridge import load_service_components
|
from shared.sx.jinja_bridge import load_service_components
|
||||||
from shared.sx.helpers import (
|
from shared.sx.helpers import (
|
||||||
call_url, sx_call, SxExpr,
|
call_url, sx_call, SxExpr,
|
||||||
root_header_sx, full_page_sx, header_child_sx, oob_page_sx,
|
root_header_sx, full_page_sx,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load account-specific .sx components at import time
|
# Load account-specific .sx components + handlers at import time
|
||||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
load_service_components(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
service_name="account")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -137,10 +138,13 @@ def _newsletter_toggle_sx(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
|||||||
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
|
def _newsletter_toggle_off_sx(nid: int, toggle_url: str, csrf_token: str) -> str:
|
||||||
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
|
"""Render an unsubscribed newsletter toggle (no subscription record yet)."""
|
||||||
return sx_call(
|
return sx_call(
|
||||||
"account-newsletter-toggle-off",
|
"account-newsletter-toggle",
|
||||||
id=f"nl-{nid}", url=toggle_url,
|
id=f"nl-{nid}", url=toggle_url,
|
||||||
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}',
|
||||||
target=f"#nl-{nid}",
|
target=f"#nl-{nid}",
|
||||||
|
cls="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",
|
||||||
|
checked="false",
|
||||||
|
knob_cls="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -196,10 +200,10 @@ def _login_page_content(ctx: dict) -> str:
|
|||||||
email = ctx.get("email", "")
|
email = ctx.get("email", "")
|
||||||
action = url_for("auth.start_login")
|
action = url_for("auth.start_login")
|
||||||
|
|
||||||
error_sx = sx_call("account-login-error", error=error) if error else ""
|
error_sx = sx_call("auth-error-banner", error=error) if error else ""
|
||||||
|
|
||||||
return sx_call(
|
return sx_call(
|
||||||
"account-login-form",
|
"auth-login-form",
|
||||||
error=SxExpr(error_sx) if error_sx else None,
|
error=SxExpr(error_sx) if error_sx else None,
|
||||||
action=action,
|
action=action,
|
||||||
csrf_token=generate_csrf_token(), email=email,
|
csrf_token=generate_csrf_token(), email=email,
|
||||||
@@ -234,80 +238,23 @@ def _device_approved_content() -> str:
|
|||||||
# Public API: Account dashboard
|
# Public API: Account dashboard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def render_account_page(ctx: dict) -> str:
|
|
||||||
"""Full page: account dashboard."""
|
|
||||||
main = _account_main_panel_sx(ctx)
|
|
||||||
|
|
||||||
hdr = root_header_sx(ctx)
|
|
||||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
|
||||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
|
||||||
|
|
||||||
return full_page_sx(ctx, header_rows=header_rows,
|
|
||||||
content=main,
|
|
||||||
menu=_auth_nav_mobile_sx(ctx))
|
|
||||||
|
|
||||||
|
|
||||||
async def render_account_oob(ctx: dict) -> str:
|
|
||||||
"""OOB response for account dashboard."""
|
|
||||||
main = _account_main_panel_sx(ctx)
|
|
||||||
|
|
||||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
|
||||||
|
|
||||||
return oob_page_sx(oobs=oobs,
|
|
||||||
content=main,
|
|
||||||
menu=_auth_nav_mobile_sx(ctx))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def _fragment_content(frag: object) -> str:
|
||||||
# Public API: Newsletters
|
"""Convert a fragment response to sx content string.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
SxExpr (from text/sx responses) is embedded as-is; plain strings
|
||||||
"""Full page: newsletters."""
|
(from text/html) are wrapped in ``~rich-text``.
|
||||||
main = _newsletters_panel_sx(ctx, newsletter_list)
|
"""
|
||||||
|
from shared.sx.parser import SxExpr
|
||||||
hdr = root_header_sx(ctx)
|
if isinstance(frag, SxExpr):
|
||||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
return frag.source
|
||||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
s = str(frag) if frag else ""
|
||||||
|
if not s:
|
||||||
return full_page_sx(ctx, header_rows=header_rows,
|
return ""
|
||||||
content=main,
|
return f'(~rich-text :html "{_sx_escape(s)}")'
|
||||||
menu=_auth_nav_mobile_sx(ctx))
|
|
||||||
|
|
||||||
|
|
||||||
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
|
||||||
"""OOB response for newsletters."""
|
|
||||||
main = _newsletters_panel_sx(ctx, newsletter_list)
|
|
||||||
|
|
||||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
|
||||||
|
|
||||||
return oob_page_sx(oobs=oobs,
|
|
||||||
content=main,
|
|
||||||
menu=_auth_nav_mobile_sx(ctx))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API: Fragment pages
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
|
||||||
"""Full page: fragment-provided content."""
|
|
||||||
hdr = root_header_sx(ctx)
|
|
||||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
|
||||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
|
||||||
|
|
||||||
return full_page_sx(ctx, header_rows=header_rows,
|
|
||||||
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
|
|
||||||
menu=_auth_nav_mobile_sx(ctx))
|
|
||||||
|
|
||||||
|
|
||||||
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
|
|
||||||
"""OOB response for fragment pages."""
|
|
||||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
|
||||||
|
|
||||||
return oob_page_sx(oobs=oobs,
|
|
||||||
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")',
|
|
||||||
menu=_auth_nav_mobile_sx(ctx))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -347,11 +294,11 @@ def _check_email_content(email: str, email_error: str | None = None) -> str:
|
|||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
|
||||||
error_sx = sx_call(
|
error_sx = sx_call(
|
||||||
"account-check-email-error", error=str(escape(email_error))
|
"auth-check-email-error", error=str(escape(email_error))
|
||||||
) if email_error else ""
|
) if email_error else ""
|
||||||
|
|
||||||
return sx_call(
|
return sx_call(
|
||||||
"account-check-email",
|
"auth-check-email",
|
||||||
email=str(escape(email)),
|
email=str(escape(email)),
|
||||||
error=SxExpr(error_sx) if error_sx else None,
|
error=SxExpr(error_sx) if error_sx else None,
|
||||||
)
|
)
|
||||||
|
|||||||
0
account/sxc/__init__.py
Normal file
0
account/sxc/__init__.py
Normal file
105
account/sxc/pages/__init__.py
Normal file
105
account/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Account defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def setup_account_pages() -> None:
|
||||||
|
"""Register account-specific layouts, page helpers, and load page definitions."""
|
||||||
|
_register_account_layouts()
|
||||||
|
_register_account_helpers()
|
||||||
|
_load_account_page_files()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_account_page_files() -> None:
|
||||||
|
import os
|
||||||
|
from shared.sx.pages import load_page_dir
|
||||||
|
load_page_dir(os.path.dirname(__file__), "account")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layouts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_account_layouts() -> None:
|
||||||
|
from shared.sx.layouts import register_custom_layout
|
||||||
|
register_custom_layout("account", _account_full, _account_oob, _account_mobile)
|
||||||
|
|
||||||
|
|
||||||
|
def _account_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
from shared.sx.helpers import root_header_sx, header_child_sx
|
||||||
|
from sx.sx_components import _auth_header_sx
|
||||||
|
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||||
|
return "(<> " + root_hdr + " " + hdr_child + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _account_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
from shared.sx.helpers import root_header_sx
|
||||||
|
from sx.sx_components import _auth_header_sx
|
||||||
|
|
||||||
|
return "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _account_mobile(ctx: dict, **kw: Any) -> str:
|
||||||
|
from shared.sx.helpers import mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr
|
||||||
|
from sx.sx_components import _auth_nav_mobile_sx
|
||||||
|
ctx = _inject_account_nav(ctx)
|
||||||
|
auth_section = sx_call("mobile-menu-section",
|
||||||
|
label="account", href="/", level=1, colour="sky",
|
||||||
|
items=SxExpr(_auth_nav_mobile_sx(ctx)))
|
||||||
|
return mobile_menu_sx(auth_section, mobile_root_nav_sx(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_account_nav(ctx: dict) -> dict:
|
||||||
|
"""Ensure account_nav is in ctx from g.account_nav."""
|
||||||
|
if "account_nav" not in ctx:
|
||||||
|
from quart import g
|
||||||
|
ctx = dict(ctx)
|
||||||
|
ctx["account_nav"] = getattr(g, "account_nav", "")
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_account_helpers() -> None:
|
||||||
|
from shared.sx.pages import register_page_helpers
|
||||||
|
|
||||||
|
register_page_helpers("account", {
|
||||||
|
"account-content": _h_account_content,
|
||||||
|
"newsletters-content": _h_newsletters_content,
|
||||||
|
"fragment-content": _h_fragment_content,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _h_account_content():
|
||||||
|
from sx.sx_components import _account_main_panel_sx
|
||||||
|
return _account_main_panel_sx({})
|
||||||
|
|
||||||
|
|
||||||
|
def _h_newsletters_content():
|
||||||
|
from quart import g
|
||||||
|
d = getattr(g, "newsletters_data", None)
|
||||||
|
if not d:
|
||||||
|
from shared.sx.helpers import sx_call
|
||||||
|
return sx_call("account-newsletter-empty")
|
||||||
|
from shared.sx.page import get_template_context_sync
|
||||||
|
from sx.sx_components import _newsletters_panel_sx
|
||||||
|
# Build a minimal ctx with account_url
|
||||||
|
ctx = {"account_url": getattr(g, "_account_url", None)}
|
||||||
|
if ctx["account_url"] is None:
|
||||||
|
from shared.infrastructure.urls import account_url
|
||||||
|
ctx["account_url"] = account_url
|
||||||
|
return _newsletters_panel_sx(ctx, d)
|
||||||
|
|
||||||
|
|
||||||
|
def _h_fragment_content():
|
||||||
|
from quart import g
|
||||||
|
frag = getattr(g, "fragment_page_data", None)
|
||||||
|
if not frag:
|
||||||
|
return ""
|
||||||
|
from sx.sx_components import _fragment_content
|
||||||
|
return _fragment_content(frag)
|
||||||
31
account/sxc/pages/account.sx
Normal file
31
account/sxc/pages/account.sx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
;; Account app — declarative page definitions
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Account dashboard
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage account-dashboard
|
||||||
|
:path "/"
|
||||||
|
:auth :login
|
||||||
|
:layout :account
|
||||||
|
:content (account-content))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Newsletters
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage newsletters
|
||||||
|
:path "/newsletters/"
|
||||||
|
:auth :login
|
||||||
|
:layout :account
|
||||||
|
:content (newsletters-content))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Fragment pages (tickets, bookings, etc. from events service)
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage fragment-page
|
||||||
|
:path "/<slug>/"
|
||||||
|
:auth :login
|
||||||
|
:layout :account
|
||||||
|
:content (fragment-content))
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<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">Bookings</h1>
|
|
||||||
|
|
||||||
{% if bookings %}
|
|
||||||
<div class="divide-y divide-stone-100">
|
|
||||||
{% for booking in bookings %}
|
|
||||||
<div class="py-4 first:pt-0 last:pb-0">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
|
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
|
||||||
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
|
||||||
{% if booking.end_at %}
|
|
||||||
<span>– {{ booking.end_at.strftime('%H:%M') }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if booking.calendar_name %}
|
|
||||||
<span>· {{ booking.calendar_name }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if booking.cost %}
|
|
||||||
<span>· £{{ booking.cost }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
{% if booking.state == 'confirmed' %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
|
||||||
{% elif booking.state == 'provisional' %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-stone-500">No bookings yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{ page_fragment_html | safe }}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<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="/auth/logout/" 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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
|
||||||
{% call links.link(account_url('/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
|
||||||
newsletters
|
|
||||||
{% endcall %}
|
|
||||||
{% if account_nav_html %}
|
|
||||||
{{ account_nav_html | safe }}
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
|
||||||
<button
|
|
||||||
sx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
|
|
||||||
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
|
||||||
sx-target="#nl-{{ un.newsletter_id }}"
|
|
||||||
sx-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>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<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
|
|
||||||
sx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
|
|
||||||
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
|
||||||
sx-target="#nl-{{ item.newsletter.id }}"
|
|
||||||
sx-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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<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">Tickets</h1>
|
|
||||||
|
|
||||||
{% if tickets %}
|
|
||||||
<div class="divide-y divide-stone-100">
|
|
||||||
{% for ticket in tickets %}
|
|
||||||
<div class="py-4 first:pt-0 last:pb-0">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
|
|
||||||
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
|
|
||||||
{{ ticket.entry_name }}
|
|
||||||
</a>
|
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
|
||||||
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
|
||||||
{% if ticket.calendar_name %}
|
|
||||||
<span>· {{ ticket.calendar_name }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if ticket.ticket_type_name %}
|
|
||||||
<span>· {{ ticket.ticket_type_name }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
{% if ticket.state == 'checked_in' %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
|
|
||||||
{% elif ticket.state == 'confirmed' %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-stone-500">No tickets yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{% 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="{{ blog_url('/auth/login/') }}"
|
|
||||||
class="text-stone-600 dark:text-stone-300 hover:underline"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
|
||||||
{% macro header_row(oob=False) %}
|
|
||||||
{% call links.menu_row(id='auth-row', oob=oob) %}
|
|
||||||
{% call links.link(account_url('/'), 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 %}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{% 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="{{ blog_url('/auth/start/') }}"
|
|
||||||
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 %}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends "_types/root/_index.html" %}
|
|
||||||
{% block meta %}{% endblock %}
|
|
||||||
{% block title %}Check your email — Rose Ash{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="py-8 max-w-md mx-auto text-center">
|
|
||||||
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
|
|
||||||
<p class="text-stone-600 mb-2">
|
|
||||||
We sent a sign-in link to <strong>{{ email }}</strong>.
|
|
||||||
</p>
|
|
||||||
<p class="text-stone-500 text-sm">
|
|
||||||
Click the link in the email to sign in. The link expires in 15 minutes.
|
|
||||||
</p>
|
|
||||||
{% if email_error %}
|
|
||||||
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
|
|
||||||
{{ email_error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{% extends "_types/root/_index.html" %}
|
|
||||||
{% block meta %}{% endblock %}
|
|
||||||
{% block title %}Authorize Device — Rose Ash{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="py-8 max-w-md mx-auto">
|
|
||||||
<h1 class="text-2xl font-bold mb-6">Authorize device</h1>
|
|
||||||
<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.device_submit') }}" class="space-y-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<div>
|
|
||||||
<label for="code" class="block text-sm font-medium mb-1">Device code</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="code"
|
|
||||||
id="code"
|
|
||||||
value="{{ code | default('') }}"
|
|
||||||
placeholder="XXXX-XXXX"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
maxlength="9"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
class="w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
|
||||||
>
|
|
||||||
Authorize
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{% extends "_types/root/_index.html" %}
|
|
||||||
{% block meta %}{% endblock %}
|
|
||||||
{% block title %}Device Authorized — Rose Ash{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="py-8 max-w-md mx-auto text-center">
|
|
||||||
<h1 class="text-2xl font-bold mb-4">Device authorized</h1>
|
|
||||||
<p class="text-stone-600">You can close this window and return to your terminal.</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{% extends "_types/root/_index.html" %}
|
|
||||||
{% block meta %}{% endblock %}
|
|
||||||
{% block title %}Login — Rose Ash{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="py-8 max-w-md mx-auto">
|
|
||||||
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<div>
|
|
||||||
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
id="email"
|
|
||||||
value="{{ email | default('') }}"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
|
||||||
>
|
|
||||||
Send magic link
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -2,7 +2,7 @@ from alembic import context
|
|||||||
from shared.db.alembic_env import run_alembic
|
from shared.db.alembic_env import run_alembic
|
||||||
|
|
||||||
MODELS = [
|
MODELS = [
|
||||||
"shared.models.ghost_content",
|
"blog.models.content",
|
||||||
"shared.models.kv",
|
"shared.models.kv",
|
||||||
"shared.models.menu_item",
|
"shared.models.menu_item",
|
||||||
"shared.models.menu_node",
|
"shared.models.menu_node",
|
||||||
@@ -13,6 +13,7 @@ MODELS = [
|
|||||||
|
|
||||||
TABLES = frozenset({
|
TABLES = frozenset({
|
||||||
"posts", "authors", "post_authors", "tags", "post_tags",
|
"posts", "authors", "post_authors", "tags", "post_tags",
|
||||||
|
"post_users",
|
||||||
"snippets", "tag_groups", "tag_group_tags",
|
"snippets", "tag_groups", "tag_group_tags",
|
||||||
"menu_items", "menu_nodes", "kv",
|
"menu_items", "menu_nodes", "kv",
|
||||||
"page_configs",
|
"page_configs",
|
||||||
|
|||||||
67
blog/alembic/versions/0004_ghost_id_nullable_and_defaults.py
Normal file
67
blog/alembic/versions/0004_ghost_id_nullable_and_defaults.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Make ghost_id nullable, add defaults, create post_users M2M table.
|
||||||
|
|
||||||
|
Revision ID: 0004
|
||||||
|
Revises: 0003_add_page_configs
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "blog_0004"
|
||||||
|
down_revision = "blog_0003"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Make ghost_id nullable
|
||||||
|
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||||
|
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||||
|
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=True)
|
||||||
|
|
||||||
|
# Add server defaults for Post
|
||||||
|
op.alter_column(
|
||||||
|
"posts", "uuid",
|
||||||
|
existing_type=sa.String(64),
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"posts", "updated_at",
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"posts", "created_at",
|
||||||
|
existing_type=sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create post_users M2M table (replaces post_authors for new posts)
|
||||||
|
op.create_table(
|
||||||
|
"post_users",
|
||||||
|
sa.Column("post_id", sa.Integer, sa.ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_post_users_user_id", "post_users", ["user_id"])
|
||||||
|
|
||||||
|
# Backfill post_users from post_authors for posts that already have user_id.
|
||||||
|
# This maps each post's authors to the post's user_id (primary author).
|
||||||
|
# Multi-author mapping requires the full sync script.
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO post_users (post_id, user_id, sort_order)
|
||||||
|
SELECT p.id, p.user_id, 0
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.user_id IS NOT NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("post_users")
|
||||||
|
op.alter_column("posts", "created_at", existing_type=sa.DateTime(timezone=True), server_default=None)
|
||||||
|
op.alter_column("posts", "updated_at", existing_type=sa.DateTime(timezone=True), server_default=None)
|
||||||
|
op.alter_column("posts", "uuid", existing_type=sa.String(64), server_default=None)
|
||||||
|
op.alter_column("tags", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||||
|
op.alter_column("authors", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||||
|
op.alter_column("posts", "ghost_id", existing_type=sa.String(64), nullable=False)
|
||||||
20
blog/alembic/versions/0005_add_sx_content.py
Normal file
20
blog/alembic/versions/0005_add_sx_content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Add sx_content column to posts table.
|
||||||
|
|
||||||
|
Revision ID: blog_0005
|
||||||
|
Revises: blog_0004
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "blog_0005"
|
||||||
|
down_revision = "blog_0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("posts", sa.Column("sx_content", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("posts", "sx_content")
|
||||||
@@ -20,6 +20,7 @@ from bp import (
|
|||||||
register_data,
|
register_data,
|
||||||
register_actions,
|
register_actions,
|
||||||
)
|
)
|
||||||
|
from sxc.pages import setup_blog_pages
|
||||||
|
|
||||||
|
|
||||||
async def blog_context() -> dict:
|
async def blog_context() -> dict:
|
||||||
@@ -80,6 +81,8 @@ async def blog_context() -> dict:
|
|||||||
def create_app() -> "Quart":
|
def create_app() -> "Quart":
|
||||||
from services import register_domain_services
|
from services import register_domain_services
|
||||||
|
|
||||||
|
setup_blog_pages()
|
||||||
|
|
||||||
app = create_base_app(
|
app = create_base_app(
|
||||||
"blog",
|
"blog",
|
||||||
context_fn=blog_context,
|
context_fn=blog_context,
|
||||||
@@ -134,7 +137,7 @@ def create_app() -> "Quart":
|
|||||||
async def oembed():
|
async def oembed():
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
from shared.services.registry import services
|
from services import blog_service
|
||||||
from shared.infrastructure.urls import blog_url
|
from shared.infrastructure.urls import blog_url
|
||||||
from shared.infrastructure.oembed import build_oembed_response
|
from shared.infrastructure.oembed import build_oembed_response
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ def create_app() -> "Quart":
|
|||||||
if not slug:
|
if not slug:
|
||||||
return jsonify({"error": "could not extract slug"}), 404
|
return jsonify({"error": "could not extract slug"}), 404
|
||||||
|
|
||||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
post = await blog_service.get_post_by_slug(g.s, slug)
|
||||||
if not post:
|
if not post:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
|
|
||||||
|
|||||||
@@ -27,33 +27,22 @@ def register(url_prefix):
|
|||||||
"base_title": f"{config()['title']} settings",
|
"base_title": f"{config()['title']} settings",
|
||||||
}
|
}
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.before_request
|
||||||
@require_admin
|
async def _prepare_page_data():
|
||||||
async def home():
|
ep = request.endpoint or ""
|
||||||
from shared.sx.page import get_template_context
|
if "defpage_settings_home" in ep:
|
||||||
from sx.sx_components import render_settings_page, render_settings_oob
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _settings_main_panel_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
g.settings_content = _settings_main_panel_sx(tctx)
|
||||||
|
elif "defpage_cache_page" in ep:
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _cache_main_panel_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
g.cache_content = _cache_main_panel_sx(tctx)
|
||||||
|
|
||||||
tctx = await get_template_context()
|
from shared.sx.pages import mount_pages
|
||||||
if not is_htmx_request():
|
mount_pages(bp, "blog", names=["settings-home", "cache-page"])
|
||||||
html = await render_settings_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_settings_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.get("/cache/")
|
|
||||||
@require_admin
|
|
||||||
async def cache():
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_cache_page, render_cache_oob
|
|
||||||
|
|
||||||
tctx = await get_template_context()
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_cache_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_cache_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.post("/cache_clear/")
|
@bp.post("/cache_clear/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -65,7 +54,7 @@ def register(url_prefix):
|
|||||||
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
|
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
|
||||||
return sx_response(html)
|
return sx_response(html)
|
||||||
|
|
||||||
return redirect(url_for("settings.cache"))
|
return redirect(url_for("settings.defpage_cache_page"))
|
||||||
return bp
|
return bp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,27 +46,52 @@ async def _unassigned_tags(session):
|
|||||||
def register():
|
def register():
|
||||||
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
|
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.before_request
|
||||||
@require_admin
|
async def _prepare_page_data():
|
||||||
async def index():
|
ep = request.endpoint or ""
|
||||||
groups = list(
|
if "defpage_tag_groups_page" in ep:
|
||||||
(await g.s.execute(
|
groups = list(
|
||||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
(await g.s.execute(
|
||||||
)).scalars()
|
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||||
)
|
)).scalars()
|
||||||
unassigned = await _unassigned_tags(g.s)
|
)
|
||||||
|
unassigned = await _unassigned_tags(g.s)
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _tag_groups_main_panel_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update({"groups": groups, "unassigned_tags": unassigned})
|
||||||
|
g.tag_groups_content = _tag_groups_main_panel_sx(tctx)
|
||||||
|
elif "defpage_tag_group_edit" in ep:
|
||||||
|
tag_id = (request.view_args or {}).get("id")
|
||||||
|
tg = await g.s.get(TagGroup, tag_id)
|
||||||
|
if not tg:
|
||||||
|
from quart import abort
|
||||||
|
abort(404)
|
||||||
|
assigned_rows = list(
|
||||||
|
(await g.s.execute(
|
||||||
|
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == tag_id)
|
||||||
|
)).scalars()
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _tag_groups_edit_main_panel_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update({
|
||||||
|
"group": tg,
|
||||||
|
"all_tags": all_tags,
|
||||||
|
"assigned_tag_ids": set(assigned_rows),
|
||||||
|
})
|
||||||
|
g.tag_group_edit_content = _tag_groups_edit_main_panel_sx(tctx)
|
||||||
|
|
||||||
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
from shared.sx.pages import mount_pages
|
||||||
|
mount_pages(bp, "blog", names=["tag-groups-page", "tag-group-edit"])
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_tag_groups_page, render_tag_groups_oob
|
|
||||||
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx.update(ctx)
|
|
||||||
if not is_htmx_request():
|
|
||||||
return await make_response(await render_tag_groups_page(tctx))
|
|
||||||
else:
|
|
||||||
return sx_response(await render_tag_groups_oob(tctx))
|
|
||||||
|
|
||||||
@bp.post("/")
|
@bp.post("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -74,7 +99,7 @@ def register():
|
|||||||
form = await request.form
|
form = await request.form
|
||||||
name = (form.get("name") or "").strip()
|
name = (form.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||||
|
|
||||||
slug = _slugify(name)
|
slug = _slugify(name)
|
||||||
feature_image = (form.get("feature_image") or "").strip() or None
|
feature_image = (form.get("feature_image") or "").strip() or None
|
||||||
@@ -90,55 +115,14 @@ def register():
|
|||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
await invalidate_tag_cache("blog")
|
await invalidate_tag_cache("blog")
|
||||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||||
|
|
||||||
@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,
|
|
||||||
}
|
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_tag_group_edit_page, render_tag_group_edit_oob
|
|
||||||
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx.update(ctx)
|
|
||||||
if not is_htmx_request():
|
|
||||||
return await make_response(await render_tag_group_edit_page(tctx))
|
|
||||||
else:
|
|
||||||
return sx_response(await render_tag_group_edit_oob(tctx))
|
|
||||||
|
|
||||||
@bp.post("/<int:id>/")
|
@bp.post("/<int:id>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def save(id: int):
|
async def save(id: int):
|
||||||
tg = await g.s.get(TagGroup, id)
|
tg = await g.s.get(TagGroup, id)
|
||||||
if not tg:
|
if not tg:
|
||||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||||
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
name = (form.get("name") or "").strip()
|
name = (form.get("name") or "").strip()
|
||||||
@@ -169,7 +153,7 @@ def register():
|
|||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
await invalidate_tag_cache("blog")
|
await invalidate_tag_cache("blog")
|
||||||
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
|
return redirect(url_for("blog.tag_groups_admin.defpage_tag_group_edit", id=id))
|
||||||
|
|
||||||
@bp.post("/<int:id>/delete/")
|
@bp.post("/<int:id>/delete/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -179,6 +163,6 @@ def register():
|
|||||||
await g.s.delete(tg)
|
await g.s.delete(tg)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
await invalidate_tag_cache("blog")
|
await invalidate_tag_cache("blog")
|
||||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
return redirect(url_for("blog.tag_groups_admin.defpage_tag_groups_page"))
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
445
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
445
blog/bp/blog/ghost/lexical_to_sx.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""
|
||||||
|
Lexical JSON → s-expression converter.
|
||||||
|
|
||||||
|
Mirrors lexical_renderer.py's registry/dispatch pattern but produces sx source
|
||||||
|
instead of HTML. Used for backfilling existing posts and on-the-fly conversion
|
||||||
|
when editing pre-migration posts in the SX editor.
|
||||||
|
|
||||||
|
Public API
|
||||||
|
----------
|
||||||
|
lexical_to_sx(doc) – Lexical JSON (dict or string) → sx source string
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import mistune
|
||||||
|
|
||||||
|
from shared.sx.html_to_sx import html_to_sx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CONVERTERS: dict[str, Callable[[dict], str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _converter(node_type: str):
|
||||||
|
"""Decorator — register a function as the converter for *node_type*."""
|
||||||
|
def decorator(fn: Callable[[dict], str]) -> Callable[[dict], str]:
|
||||||
|
_CONVERTERS[node_type] = fn
|
||||||
|
return fn
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def lexical_to_sx(doc: dict | str) -> str:
|
||||||
|
"""Convert a Lexical JSON document to an sx source string."""
|
||||||
|
if isinstance(doc, str):
|
||||||
|
doc = json.loads(doc)
|
||||||
|
root = doc.get("root", doc)
|
||||||
|
children = root.get("children", [])
|
||||||
|
parts = [_convert_node(c) for c in children]
|
||||||
|
parts = [p for p in parts if p]
|
||||||
|
if not parts:
|
||||||
|
return '(<> (p ""))'
|
||||||
|
if len(parts) == 1:
|
||||||
|
return parts[0]
|
||||||
|
return "(<>\n " + "\n ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _convert_node(node: dict) -> str:
|
||||||
|
node_type = node.get("type", "")
|
||||||
|
converter = _CONVERTERS.get(node_type)
|
||||||
|
if converter:
|
||||||
|
return converter(node)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_children(children: list[dict]) -> str:
|
||||||
|
"""Convert children to inline sx content (for text nodes)."""
|
||||||
|
parts = [_convert_node(c) for c in children]
|
||||||
|
return " ".join(p for p in parts if p)
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(s: str) -> str:
|
||||||
|
"""Escape a string for sx double-quoted literals."""
|
||||||
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Text format bitmask
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FORMAT_BOLD = 1
|
||||||
|
_FORMAT_ITALIC = 2
|
||||||
|
_FORMAT_STRIKETHROUGH = 4
|
||||||
|
_FORMAT_UNDERLINE = 8
|
||||||
|
_FORMAT_CODE = 16
|
||||||
|
_FORMAT_SUBSCRIPT = 32
|
||||||
|
_FORMAT_SUPERSCRIPT = 64
|
||||||
|
|
||||||
|
_FORMAT_WRAPPERS: list[tuple[int, str]] = [
|
||||||
|
(_FORMAT_BOLD, "strong"),
|
||||||
|
(_FORMAT_ITALIC, "em"),
|
||||||
|
(_FORMAT_STRIKETHROUGH, "s"),
|
||||||
|
(_FORMAT_UNDERLINE, "u"),
|
||||||
|
(_FORMAT_CODE, "code"),
|
||||||
|
(_FORMAT_SUBSCRIPT, "sub"),
|
||||||
|
(_FORMAT_SUPERSCRIPT, "sup"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_format(text_sx: str, fmt: int) -> str:
|
||||||
|
for mask, tag in _FORMAT_WRAPPERS:
|
||||||
|
if fmt & mask:
|
||||||
|
text_sx = f"({tag} {text_sx})"
|
||||||
|
return text_sx
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tier 1 — text nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@_converter("text")
|
||||||
|
def _text(node: dict) -> str:
|
||||||
|
text = node.get("text", "")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
sx = f'"{_esc(text)}"'
|
||||||
|
fmt = node.get("format", 0)
|
||||||
|
if isinstance(fmt, int) and fmt:
|
||||||
|
sx = _wrap_format(sx, fmt)
|
||||||
|
return sx
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("linebreak")
|
||||||
|
def _linebreak(_node: dict) -> str:
|
||||||
|
return '"\\n"'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("tab")
|
||||||
|
def _tab(_node: dict) -> str:
|
||||||
|
return '"\\t"'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("paragraph")
|
||||||
|
def _paragraph(node: dict) -> str:
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
if not inner:
|
||||||
|
inner = '""'
|
||||||
|
return f"(p {inner})"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("extended-text")
|
||||||
|
def _extended_text(node: dict) -> str:
|
||||||
|
# extended-text can be block-level (with children) or inline (with text).
|
||||||
|
# When it has a "text" field, treat it as a plain text node.
|
||||||
|
if "text" in node:
|
||||||
|
return _text(node)
|
||||||
|
return _paragraph(node)
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("heading")
|
||||||
|
def _heading(node: dict) -> str:
|
||||||
|
tag = node.get("tag", "h2")
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
if not inner:
|
||||||
|
inner = '""'
|
||||||
|
return f"({tag} {inner})"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("extended-heading")
|
||||||
|
def _extended_heading(node: dict) -> str:
|
||||||
|
if "text" in node:
|
||||||
|
return _text(node)
|
||||||
|
return _heading(node)
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("quote")
|
||||||
|
def _quote(node: dict) -> str:
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
return f"(blockquote {inner})" if inner else '(blockquote "")'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("extended-quote")
|
||||||
|
def _extended_quote(node: dict) -> str:
|
||||||
|
if "text" in node:
|
||||||
|
return _text(node)
|
||||||
|
return _quote(node)
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("link")
|
||||||
|
def _link(node: dict) -> str:
|
||||||
|
href = node.get("url", "")
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
if not inner:
|
||||||
|
inner = f'"{_esc(href)}"'
|
||||||
|
return f'(a :href "{_esc(href)}" {inner})'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("autolink")
|
||||||
|
def _autolink(node: dict) -> str:
|
||||||
|
return _link(node)
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("at-link")
|
||||||
|
def _at_link(node: dict) -> str:
|
||||||
|
return _link(node)
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("list")
|
||||||
|
def _list(node: dict) -> str:
|
||||||
|
tag = "ol" if node.get("listType") == "number" else "ul"
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
return f"({tag} {inner})" if inner else f"({tag})"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("listitem")
|
||||||
|
def _listitem(node: dict) -> str:
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
return f"(li {inner})" if inner else '(li "")'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("horizontalrule")
|
||||||
|
def _horizontalrule(_node: dict) -> str:
|
||||||
|
return "(hr)"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("code")
|
||||||
|
def _code(node: dict) -> str:
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
return f"(code {inner})" if inner else ""
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("codeblock")
|
||||||
|
def _codeblock(node: dict) -> str:
|
||||||
|
lang = node.get("language", "")
|
||||||
|
code = node.get("code", "")
|
||||||
|
lang_attr = f' :class "language-{_esc(lang)}"' if lang else ""
|
||||||
|
return f'(pre (code{lang_attr} "{_esc(code)}"))'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("code-highlight")
|
||||||
|
def _code_highlight(node: dict) -> str:
|
||||||
|
text = node.get("text", "")
|
||||||
|
return f'"{_esc(text)}"' if text else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tier 2 — common cards
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@_converter("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", "")
|
||||||
|
|
||||||
|
parts = [f':src "{_esc(src)}"']
|
||||||
|
if alt:
|
||||||
|
parts.append(f':alt "{_esc(alt)}"')
|
||||||
|
if caption:
|
||||||
|
parts.append(f":caption {html_to_sx(caption)}")
|
||||||
|
if width:
|
||||||
|
parts.append(f':width "{_esc(width)}"')
|
||||||
|
if href:
|
||||||
|
parts.append(f':href "{_esc(href)}"')
|
||||||
|
return "(~kg-image " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("gallery")
|
||||||
|
def _gallery(node: dict) -> str:
|
||||||
|
images = node.get("images", [])
|
||||||
|
if not images:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Group images into rows of 3 (matching lexical_renderer.py)
|
||||||
|
rows = []
|
||||||
|
for i in range(0, len(images), 3):
|
||||||
|
row_imgs = images[i:i + 3]
|
||||||
|
row_items = []
|
||||||
|
for img in row_imgs:
|
||||||
|
item_parts = [f'"src" "{_esc(img.get("src", ""))}"']
|
||||||
|
if img.get("alt"):
|
||||||
|
item_parts.append(f'"alt" "{_esc(img["alt"])}"')
|
||||||
|
if img.get("caption"):
|
||||||
|
item_parts.append(f'"caption" {html_to_sx(img["caption"])}')
|
||||||
|
row_items.append("(dict " + " ".join(item_parts) + ")")
|
||||||
|
rows.append("(list " + " ".join(row_items) + ")")
|
||||||
|
|
||||||
|
images_sx = "(list " + " ".join(rows) + ")"
|
||||||
|
caption = node.get("caption", "")
|
||||||
|
caption_attr = f" :caption {html_to_sx(caption)}" if caption else ""
|
||||||
|
return f"(~kg-gallery :images {images_sx}{caption_attr})"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("html")
|
||||||
|
def _html_card(node: dict) -> str:
|
||||||
|
raw = node.get("html", "")
|
||||||
|
inner = html_to_sx(raw)
|
||||||
|
return f"(~kg-html {inner})"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("embed")
|
||||||
|
def _embed(node: dict) -> str:
|
||||||
|
embed_html = node.get("html", "")
|
||||||
|
caption = node.get("caption", "")
|
||||||
|
parts = [f':html "{_esc(embed_html)}"']
|
||||||
|
if caption:
|
||||||
|
parts.append(f":caption {html_to_sx(caption)}")
|
||||||
|
return "(~kg-embed " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("bookmark")
|
||||||
|
def _bookmark(node: dict) -> str:
|
||||||
|
url = node.get("url", "")
|
||||||
|
meta = node.get("metadata", {})
|
||||||
|
parts = [f':url "{_esc(url)}"']
|
||||||
|
|
||||||
|
title = meta.get("title", "") or node.get("title", "")
|
||||||
|
if title:
|
||||||
|
parts.append(f':title "{_esc(title)}"')
|
||||||
|
desc = meta.get("description", "") or node.get("description", "")
|
||||||
|
if desc:
|
||||||
|
parts.append(f':description "{_esc(desc)}"')
|
||||||
|
icon = meta.get("icon", "") or node.get("icon", "")
|
||||||
|
if icon:
|
||||||
|
parts.append(f':icon "{_esc(icon)}"')
|
||||||
|
author = meta.get("author", "") or node.get("author", "")
|
||||||
|
if author:
|
||||||
|
parts.append(f':author "{_esc(author)}"')
|
||||||
|
publisher = meta.get("publisher", "") or node.get("publisher", "")
|
||||||
|
if publisher:
|
||||||
|
parts.append(f':publisher "{_esc(publisher)}"')
|
||||||
|
thumbnail = meta.get("thumbnail", "") or node.get("thumbnail", "")
|
||||||
|
if thumbnail:
|
||||||
|
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||||
|
caption = node.get("caption", "")
|
||||||
|
if caption:
|
||||||
|
parts.append(f":caption {html_to_sx(caption)}")
|
||||||
|
|
||||||
|
return "(~kg-bookmark " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("callout")
|
||||||
|
def _callout(node: dict) -> str:
|
||||||
|
color = node.get("backgroundColor", "grey")
|
||||||
|
emoji = node.get("calloutEmoji", "")
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
|
||||||
|
parts = [f':color "{_esc(color)}"']
|
||||||
|
if emoji:
|
||||||
|
parts.append(f':emoji "{_esc(emoji)}"')
|
||||||
|
if inner:
|
||||||
|
parts.append(f':content {inner}')
|
||||||
|
return "(~kg-callout " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("button")
|
||||||
|
def _button(node: dict) -> str:
|
||||||
|
text = node.get("buttonText", "")
|
||||||
|
url = node.get("buttonUrl", "")
|
||||||
|
alignment = node.get("alignment", "center")
|
||||||
|
return f'(~kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("toggle")
|
||||||
|
def _toggle(node: dict) -> str:
|
||||||
|
heading = node.get("heading", "")
|
||||||
|
inner = _convert_children(node.get("children", []))
|
||||||
|
content_attr = f" :content {inner}" if inner else ""
|
||||||
|
return f'(~kg-toggle :heading "{_esc(heading)}"{content_attr})'
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("audio")
|
||||||
|
def _audio(node: dict) -> str:
|
||||||
|
src = node.get("src", "")
|
||||||
|
title = 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}"
|
||||||
|
|
||||||
|
parts = [f':src "{_esc(src)}"']
|
||||||
|
if title:
|
||||||
|
parts.append(f':title "{_esc(title)}"')
|
||||||
|
parts.append(f':duration "{duration_str}"')
|
||||||
|
if thumbnail:
|
||||||
|
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||||
|
return "(~kg-audio " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("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)
|
||||||
|
|
||||||
|
parts = [f':src "{_esc(src)}"']
|
||||||
|
if caption:
|
||||||
|
parts.append(f":caption {html_to_sx(caption)}")
|
||||||
|
if width:
|
||||||
|
parts.append(f':width "{_esc(width)}"')
|
||||||
|
if thumbnail:
|
||||||
|
parts.append(f':thumbnail "{_esc(thumbnail)}"')
|
||||||
|
if loop:
|
||||||
|
parts.append(":loop true")
|
||||||
|
return "(~kg-video " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("file")
|
||||||
|
def _file(node: dict) -> str:
|
||||||
|
src = node.get("src", "")
|
||||||
|
filename = node.get("fileName", "")
|
||||||
|
title = node.get("title", "") or filename
|
||||||
|
file_size = node.get("fileSize", 0)
|
||||||
|
caption = node.get("caption", "")
|
||||||
|
|
||||||
|
# Format size
|
||||||
|
size_str = ""
|
||||||
|
if file_size:
|
||||||
|
kb = file_size / 1024
|
||||||
|
if kb < 1024:
|
||||||
|
size_str = f"{kb:.0f} KB"
|
||||||
|
else:
|
||||||
|
size_str = f"{kb / 1024:.1f} MB"
|
||||||
|
|
||||||
|
parts = [f':src "{_esc(src)}"']
|
||||||
|
if filename:
|
||||||
|
parts.append(f':filename "{_esc(filename)}"')
|
||||||
|
if title:
|
||||||
|
parts.append(f':title "{_esc(title)}"')
|
||||||
|
if size_str:
|
||||||
|
parts.append(f':filesize "{size_str}"')
|
||||||
|
if caption:
|
||||||
|
parts.append(f":caption {html_to_sx(caption)}")
|
||||||
|
return "(~kg-file " + " ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("paywall")
|
||||||
|
def _paywall(_node: dict) -> str:
|
||||||
|
return "(~kg-paywall)"
|
||||||
|
|
||||||
|
|
||||||
|
@_converter("markdown")
|
||||||
|
def _markdown(node: dict) -> str:
|
||||||
|
md_text = node.get("markdown", "")
|
||||||
|
rendered = mistune.html(md_text)
|
||||||
|
inner = html_to_sx(rendered)
|
||||||
|
return f"(~kg-md {inner})"
|
||||||
@@ -63,6 +63,7 @@ def _post_to_public(p: Post) -> Dict[str, Any]:
|
|||||||
"slug": p.slug,
|
"slug": p.slug,
|
||||||
"title": p.title,
|
"title": p.title,
|
||||||
"html": p.html,
|
"html": p.html,
|
||||||
|
"sx_content": p.sx_content,
|
||||||
"is_page": p.is_page,
|
"is_page": p.is_page,
|
||||||
"excerpt": p.custom_excerpt or p.excerpt,
|
"excerpt": p.custom_excerpt or p.excerpt,
|
||||||
"custom_excerpt": p.custom_excerpt,
|
"custom_excerpt": p.custom_excerpt,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from quart import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from .ghost_db import DBClient # adjust import path
|
from .ghost_db import DBClient # adjust import path
|
||||||
from shared.db.session import get_session
|
|
||||||
from .filters.qs import makeqs_factory, decode
|
from .filters.qs import makeqs_factory, decode
|
||||||
from .services.posts_data import posts_data
|
from .services.posts_data import posts_data
|
||||||
from .services.pages_data import pages_data
|
from .services.pages_data import pages_data
|
||||||
@@ -47,39 +46,24 @@ def register(url_prefix, title):
|
|||||||
|
|
||||||
@blogs_bp.before_app_serving
|
@blogs_bp.before_app_serving
|
||||||
async def init():
|
async def init():
|
||||||
from .ghost.ghost_sync import sync_all_content_from_ghost
|
# Ghost startup sync disabled (Phase 1) — blog service owns content
|
||||||
from sqlalchemy import text
|
# directly. The final_ghost_sync.py script was run before cutover.
|
||||||
import logging
|
pass
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Advisory lock prevents multiple Hypercorn workers from
|
|
||||||
# running the sync concurrently (which causes PK conflicts).
|
|
||||||
async with get_session() as s:
|
|
||||||
got_lock = await s.scalar(text("SELECT pg_try_advisory_lock(900001)"))
|
|
||||||
if not got_lock:
|
|
||||||
await s.rollback() # clean up before returning connection to pool
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await sync_all_content_from_ghost(s)
|
|
||||||
await s.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Ghost sync failed — will retry on next deploy")
|
|
||||||
try:
|
|
||||||
await s.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
await s.execute(text("SELECT pg_advisory_unlock(900001)"))
|
|
||||||
await s.commit()
|
|
||||||
except Exception:
|
|
||||||
pass # lock auto-releases when session closes
|
|
||||||
|
|
||||||
@blogs_bp.before_request
|
@blogs_bp.before_request
|
||||||
def route():
|
async def route():
|
||||||
g.makeqs_factory = makeqs_factory
|
g.makeqs_factory = makeqs_factory
|
||||||
|
ep = request.endpoint or ""
|
||||||
|
if "defpage_new_post" in ep:
|
||||||
|
from sx.sx_components import render_editor_panel
|
||||||
|
g.editor_content = render_editor_panel()
|
||||||
|
elif "defpage_new_page" in ep:
|
||||||
|
from sx.sx_components import render_editor_panel
|
||||||
|
g.editor_page_content = render_editor_panel(is_page=True)
|
||||||
|
|
||||||
|
from shared.sx.pages import mount_pages
|
||||||
|
mount_pages(blogs_bp, "blog", names=["new-post", "new-page"])
|
||||||
|
|
||||||
|
|
||||||
@blogs_bp.context_processor
|
@blogs_bp.context_processor
|
||||||
async def inject_root():
|
async def inject_root():
|
||||||
return {
|
return {
|
||||||
@@ -240,27 +224,11 @@ def register(url_prefix, title):
|
|||||||
sx_src = await render_blog_oob(tctx)
|
sx_src = await render_blog_oob(tctx)
|
||||||
return sx_response(sx_src)
|
return sx_response(sx_src)
|
||||||
|
|
||||||
@blogs_bp.get("/new/")
|
|
||||||
@require_admin
|
|
||||||
async def new_post():
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
|
||||||
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx["editor_html"] = render_editor_panel()
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_new_post_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_new_post_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@blogs_bp.post("/new/")
|
@blogs_bp.post("/new/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def new_post_save():
|
async def new_post_save():
|
||||||
from .ghost.ghost_posts import create_post
|
|
||||||
from .ghost.lexical_validator import validate_lexical
|
from .ghost.lexical_validator import validate_lexical
|
||||||
from .ghost.ghost_sync import sync_single_post
|
from services.post_writer import create_post as writer_create
|
||||||
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
title = form.get("title", "").strip() or "Untitled"
|
title = form.get("title", "").strip() or "Untitled"
|
||||||
@@ -290,59 +258,33 @@ def register(url_prefix, title):
|
|||||||
html = await render_new_post_page(tctx)
|
html = await render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
# Create in Ghost
|
# Create directly in db_blog
|
||||||
ghost_post = await create_post(
|
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||||
|
post = await writer_create(
|
||||||
|
g.s,
|
||||||
title=title,
|
title=title,
|
||||||
lexical_json=lexical_raw,
|
lexical_json=lexical_raw,
|
||||||
status=status,
|
status=status,
|
||||||
|
user_id=g.user.id,
|
||||||
feature_image=feature_image or None,
|
feature_image=feature_image or None,
|
||||||
custom_excerpt=custom_excerpt or None,
|
custom_excerpt=custom_excerpt or None,
|
||||||
feature_image_caption=feature_image_caption or None,
|
feature_image_caption=feature_image_caption or None,
|
||||||
|
sx_content=sx_content_raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync to local DB
|
|
||||||
await sync_single_post(g.s, ghost_post["id"])
|
|
||||||
await g.s.flush()
|
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
|
# Clear blog listing cache
|
||||||
await invalidate_tag_cache("blog")
|
await invalidate_tag_cache("blog")
|
||||||
|
|
||||||
# Redirect to the edit page (post is likely a draft, so public detail would 404)
|
# Redirect to the edit page
|
||||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_post["slug"])))
|
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)))
|
||||||
|
|
||||||
|
|
||||||
@blogs_bp.get("/new-page/")
|
|
||||||
@require_admin
|
|
||||||
async def new_page():
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_new_post_page, render_new_post_oob, render_editor_panel
|
|
||||||
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx["editor_html"] = render_editor_panel(is_page=True)
|
|
||||||
tctx["is_page"] = True
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_new_post_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_new_post_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@blogs_bp.post("/new-page/")
|
@blogs_bp.post("/new-page/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def new_page_save():
|
async def new_page_save():
|
||||||
from .ghost.ghost_posts import create_page
|
|
||||||
from .ghost.lexical_validator import validate_lexical
|
from .ghost.lexical_validator import validate_lexical
|
||||||
from .ghost.ghost_sync import sync_single_page
|
from services.post_writer import create_page as writer_create_page
|
||||||
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
title = form.get("title", "").strip() or "Untitled"
|
title = form.get("title", "").strip() or "Untitled"
|
||||||
@@ -374,35 +316,26 @@ def register(url_prefix, title):
|
|||||||
html = await render_new_post_page(tctx)
|
html = await render_new_post_page(tctx)
|
||||||
return await make_response(html, 400)
|
return await make_response(html, 400)
|
||||||
|
|
||||||
# Create in Ghost (as page)
|
# Create directly in db_blog
|
||||||
ghost_page = await create_page(
|
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||||
|
page = await writer_create_page(
|
||||||
|
g.s,
|
||||||
title=title,
|
title=title,
|
||||||
lexical_json=lexical_raw,
|
lexical_json=lexical_raw,
|
||||||
status=status,
|
status=status,
|
||||||
|
user_id=g.user.id,
|
||||||
feature_image=feature_image or None,
|
feature_image=feature_image or None,
|
||||||
custom_excerpt=custom_excerpt or None,
|
custom_excerpt=custom_excerpt or None,
|
||||||
feature_image_caption=feature_image_caption or None,
|
feature_image_caption=feature_image_caption or None,
|
||||||
|
sx_content=sx_content_raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync to local DB (uses pages endpoint)
|
|
||||||
await sync_single_page(g.s, ghost_page["id"])
|
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
# Set user_id on the newly created page
|
|
||||||
from models.ghost_content import Post
|
|
||||||
from sqlalchemy import select
|
|
||||||
local_post = (await g.s.execute(
|
|
||||||
select(Post).where(Post.ghost_id == ghost_page["id"])
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
if local_post and local_post.user_id is None:
|
|
||||||
local_post.user_id = g.user.id
|
|
||||||
await g.s.flush()
|
|
||||||
|
|
||||||
# Clear blog listing cache
|
# Clear blog listing cache
|
||||||
await invalidate_tag_cache("blog")
|
await invalidate_tag_cache("blog")
|
||||||
|
|
||||||
# Redirect to the page admin
|
# Redirect to the page admin
|
||||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=ghost_page["slug"])))
|
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=page.slug)))
|
||||||
|
|
||||||
|
|
||||||
@blogs_bp.get("/drafts/")
|
@blogs_bp.get("/drafts/")
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
# suma_browser/webhooks.py
|
# Ghost webhooks — neutered (Phase 1).
|
||||||
|
#
|
||||||
|
# Post/page/author/tag handlers return 204 no-op.
|
||||||
|
# Member webhook remains active (membership sync handled by account service).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
from quart import Blueprint, request, abort, Response, g
|
from quart import Blueprint, request, abort, Response
|
||||||
|
|
||||||
from ..ghost.ghost_sync import (
|
|
||||||
sync_single_page,
|
|
||||||
sync_single_post,
|
|
||||||
sync_single_author,
|
|
||||||
sync_single_tag,
|
|
||||||
)
|
|
||||||
from shared.browser.app.redis_cacher import clear_cache
|
|
||||||
from shared.browser.app.csrf import csrf_exempt
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
|
ghost_webhooks = Blueprint("ghost_webhooks", __name__, url_prefix="/__ghost-webhook")
|
||||||
@@ -32,6 +28,7 @@ def _extract_id(data: dict, key: str) -> str | None:
|
|||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@ghost_webhooks.route("/member/", methods=["POST"])
|
@ghost_webhooks.route("/member/", methods=["POST"])
|
||||||
async def webhook_member() -> Response:
|
async def webhook_member() -> Response:
|
||||||
|
"""Member webhook still active — delegates to account service."""
|
||||||
_check_secret(request)
|
_check_secret(request)
|
||||||
|
|
||||||
data = await request.get_json(force=True, silent=True) or {}
|
data = await request.get_json(force=True, silent=True) or {}
|
||||||
@@ -39,7 +36,6 @@ async def webhook_member() -> Response:
|
|||||||
if not ghost_id:
|
if not ghost_id:
|
||||||
abort(400, "no member id")
|
abort(400, "no member id")
|
||||||
|
|
||||||
# Delegate to account service (membership data lives in db_account)
|
|
||||||
from shared.infrastructure.actions import call_action
|
from shared.infrastructure.actions import call_action
|
||||||
try:
|
try:
|
||||||
await call_action(
|
await call_action(
|
||||||
@@ -52,61 +48,25 @@ async def webhook_member() -> Response:
|
|||||||
logging.getLogger(__name__).error("Member sync via account failed: %s", e)
|
logging.getLogger(__name__).error("Member sync via account failed: %s", e)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Neutered handlers: Ghost no longer writes content ---
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@ghost_webhooks.post("/post/")
|
@ghost_webhooks.post("/post/")
|
||||||
@clear_cache(tag='blog')
|
|
||||||
async def webhook_post() -> Response:
|
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")
|
|
||||||
|
|
||||||
await sync_single_post(g.s, ghost_id)
|
|
||||||
|
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@ghost_webhooks.post("/page/")
|
@ghost_webhooks.post("/page/")
|
||||||
@clear_cache(tag='blog')
|
|
||||||
async def webhook_page() -> Response:
|
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")
|
|
||||||
|
|
||||||
await sync_single_page(g.s, ghost_id)
|
|
||||||
|
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@ghost_webhooks.post("/author/")
|
@ghost_webhooks.post("/author/")
|
||||||
@clear_cache(tag='blog')
|
|
||||||
async def webhook_author() -> Response:
|
async def webhook_author() -> Response:
|
||||||
_check_secret(request)
|
|
||||||
|
|
||||||
data = await request.get_json(force=True, silent=True) or {}
|
|
||||||
ghost_id = _extract_id(data, "user") or _extract_id(data, "author")
|
|
||||||
if not ghost_id:
|
|
||||||
abort(400, "no author id")
|
|
||||||
|
|
||||||
await sync_single_author(g.s, ghost_id)
|
|
||||||
|
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@ghost_webhooks.post("/tag/")
|
@ghost_webhooks.post("/tag/")
|
||||||
@clear_cache(tag='blog')
|
|
||||||
async def webhook_tag() -> Response:
|
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")
|
|
||||||
|
|
||||||
await sync_single_tag(g.s, ghost_id)
|
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from quart import Blueprint, g, jsonify, request
|
|||||||
|
|
||||||
from shared.infrastructure.data_client import DATA_HEADER
|
from shared.infrastructure.data_client import DATA_HEADER
|
||||||
from shared.contracts.dtos import dto_to_dict
|
from shared.contracts.dtos import dto_to_dict
|
||||||
from shared.services.registry import services
|
from services import blog_service
|
||||||
|
|
||||||
|
|
||||||
def register() -> Blueprint:
|
def register() -> Blueprint:
|
||||||
@@ -36,7 +36,7 @@ def register() -> Blueprint:
|
|||||||
# --- post-by-slug ---
|
# --- post-by-slug ---
|
||||||
async def _post_by_slug():
|
async def _post_by_slug():
|
||||||
slug = request.args.get("slug", "")
|
slug = request.args.get("slug", "")
|
||||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
post = await blog_service.get_post_by_slug(g.s, slug)
|
||||||
if not post:
|
if not post:
|
||||||
return None
|
return None
|
||||||
return dto_to_dict(post)
|
return dto_to_dict(post)
|
||||||
@@ -46,7 +46,7 @@ def register() -> Blueprint:
|
|||||||
# --- post-by-id ---
|
# --- post-by-id ---
|
||||||
async def _post_by_id():
|
async def _post_by_id():
|
||||||
post_id = int(request.args.get("id", 0))
|
post_id = int(request.args.get("id", 0))
|
||||||
post = await services.blog.get_post_by_id(g.s, post_id)
|
post = await blog_service.get_post_by_id(g.s, post_id)
|
||||||
if not post:
|
if not post:
|
||||||
return None
|
return None
|
||||||
return dto_to_dict(post)
|
return dto_to_dict(post)
|
||||||
@@ -59,7 +59,7 @@ def register() -> Blueprint:
|
|||||||
if not ids_raw:
|
if not ids_raw:
|
||||||
return []
|
return []
|
||||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||||
posts = await services.blog.get_posts_by_ids(g.s, ids)
|
posts = await blog_service.get_posts_by_ids(g.s, ids)
|
||||||
return [dto_to_dict(p) for p in posts]
|
return [dto_to_dict(p) for p in posts]
|
||||||
|
|
||||||
_handlers["posts-by-ids"] = _posts_by_ids
|
_handlers["posts-by-ids"] = _posts_by_ids
|
||||||
@@ -69,7 +69,7 @@ def register() -> Blueprint:
|
|||||||
query = request.args.get("query", "")
|
query = request.args.get("query", "")
|
||||||
page = int(request.args.get("page", 1))
|
page = int(request.args.get("page", 1))
|
||||||
per_page = int(request.args.get("per_page", 10))
|
per_page = int(request.args.get("per_page", 10))
|
||||||
posts, total = await services.blog.search_posts(g.s, query, page, per_page)
|
posts, total = await blog_service.search_posts(g.s, query, page, per_page)
|
||||||
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
|
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
|
||||||
|
|
||||||
_handlers["search-posts"] = _search_posts
|
_handlers["search-posts"] = _search_posts
|
||||||
|
|||||||
@@ -2,21 +2,22 @@
|
|||||||
|
|
||||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
by other coop apps via the fragment client.
|
by other coop apps via the fragment client.
|
||||||
|
|
||||||
|
All handlers are defined declaratively in .sx files under
|
||||||
|
``blog/sx/handlers/`` and dispatched via the sx handler registry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from quart import Blueprint, Response, g, render_template, request
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
from shared.services.navigation import get_navigation_tree
|
from shared.sx.handlers import get_handler, execute_handler
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
_handlers: dict[str, object] = {}
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def _require_fragment_header():
|
async def _require_fragment_header():
|
||||||
if not request.headers.get(FRAGMENT_HEADER):
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
@@ -24,134 +25,12 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/<fragment_type>")
|
@bp.get("/<fragment_type>")
|
||||||
async def get_fragment(fragment_type: str):
|
async def get_fragment(fragment_type: str):
|
||||||
handler = _handlers.get(fragment_type)
|
handler_def = get_handler("blog", fragment_type)
|
||||||
if handler is None:
|
if handler_def is not None:
|
||||||
return Response("", status=200, content_type="text/sx")
|
result = await execute_handler(
|
||||||
result = await handler()
|
handler_def, "blog", args=dict(request.args),
|
||||||
return Response(result, status=200, content_type="text/sx")
|
)
|
||||||
|
return Response(result, status=200, content_type="text/sx")
|
||||||
# --- nav-tree fragment — returns sx source ---
|
return Response("", status=200, content_type="text/sx")
|
||||||
async def _nav_tree_handler():
|
|
||||||
from shared.sx.helpers import sx_call, SxExpr
|
|
||||||
from shared.infrastructure.urls import (
|
|
||||||
blog_url, cart_url, market_url, events_url,
|
|
||||||
federation_url, account_url, artdag_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
app_name = request.args.get("app_name", "")
|
|
||||||
path = request.args.get("path", "/")
|
|
||||||
first_seg = path.strip("/").split("/")[0]
|
|
||||||
menu_items = list(await get_navigation_tree(g.s))
|
|
||||||
|
|
||||||
app_slugs = {
|
|
||||||
"cart": cart_url("/"),
|
|
||||||
"market": market_url("/"),
|
|
||||||
"events": events_url("/"),
|
|
||||||
"federation": federation_url("/"),
|
|
||||||
"account": account_url("/"),
|
|
||||||
"artdag": artdag_url("/"),
|
|
||||||
}
|
|
||||||
|
|
||||||
nav_cls = "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm"
|
|
||||||
|
|
||||||
item_sxs = []
|
|
||||||
for item in menu_items:
|
|
||||||
href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/"))
|
|
||||||
selected = "true" if (item.slug == first_seg
|
|
||||||
or item.slug == app_name) else "false"
|
|
||||||
img = sx_call("blog-nav-item-image",
|
|
||||||
src=getattr(item, "feature_image", None),
|
|
||||||
label=getattr(item, "label", item.slug))
|
|
||||||
item_sxs.append(sx_call(
|
|
||||||
"blog-nav-item-link",
|
|
||||||
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
|
|
||||||
img=SxExpr(img), label=getattr(item, "label", item.slug),
|
|
||||||
))
|
|
||||||
|
|
||||||
# artdag link
|
|
||||||
href = artdag_url("/")
|
|
||||||
selected = "true" if ("artdag" == first_seg
|
|
||||||
or "artdag" == app_name) else "false"
|
|
||||||
img = sx_call("blog-nav-item-image", src=None, label="art-dag")
|
|
||||||
item_sxs.append(sx_call(
|
|
||||||
"blog-nav-item-link",
|
|
||||||
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
|
|
||||||
img=SxExpr(img), label="art-dag",
|
|
||||||
))
|
|
||||||
|
|
||||||
if not item_sxs:
|
|
||||||
return sx_call("blog-nav-empty",
|
|
||||||
wrapper_id="menu-items-nav-wrapper")
|
|
||||||
|
|
||||||
items_frag = "(<> " + " ".join(item_sxs) + ")"
|
|
||||||
|
|
||||||
arrow_cls = "scrolling-menu-arrow-menu-items-container"
|
|
||||||
container_id = "menu-items-container"
|
|
||||||
left_hs = ("on click set #" + container_id
|
|
||||||
+ ".scrollLeft to #" + container_id + ".scrollLeft - 200")
|
|
||||||
scroll_hs = ("on scroll "
|
|
||||||
"set cls to '" + arrow_cls + "' "
|
|
||||||
"set arrows to document.getElementsByClassName(cls) "
|
|
||||||
"set show to (window.innerWidth >= 640 and "
|
|
||||||
"my.scrollWidth > my.clientWidth) "
|
|
||||||
"repeat for arrow in arrows "
|
|
||||||
"if show remove .hidden from arrow add .flex to arrow "
|
|
||||||
"else add .hidden to arrow remove .flex from arrow end "
|
|
||||||
"end")
|
|
||||||
right_hs = ("on click set #" + container_id
|
|
||||||
+ ".scrollLeft to #" + container_id + ".scrollLeft + 200")
|
|
||||||
|
|
||||||
return sx_call("blog-nav-wrapper",
|
|
||||||
arrow_cls=arrow_cls,
|
|
||||||
container_id=container_id,
|
|
||||||
left_hs=left_hs,
|
|
||||||
scroll_hs=scroll_hs,
|
|
||||||
right_hs=right_hs,
|
|
||||||
items=SxExpr(items_frag))
|
|
||||||
|
|
||||||
_handlers["nav-tree"] = _nav_tree_handler
|
|
||||||
|
|
||||||
# --- link-card fragment — returns sx source ---
|
|
||||||
def _blog_link_card_sx(post, link: str) -> str:
|
|
||||||
from shared.sx.helpers import sx_call
|
|
||||||
published = post.published_at.strftime("%d %b %Y") if post.published_at else None
|
|
||||||
return sx_call("link-card",
|
|
||||||
link=link,
|
|
||||||
title=post.title,
|
|
||||||
image=post.feature_image,
|
|
||||||
icon="fas fa-file-alt",
|
|
||||||
subtitle=post.custom_excerpt or post.excerpt,
|
|
||||||
detail=published,
|
|
||||||
data_app="blog")
|
|
||||||
|
|
||||||
async def _link_card_handler():
|
|
||||||
from shared.services.registry import services
|
|
||||||
from shared.infrastructure.urls import blog_url
|
|
||||||
|
|
||||||
slug = request.args.get("slug", "")
|
|
||||||
keys_raw = request.args.get("keys", "")
|
|
||||||
|
|
||||||
# Batch mode
|
|
||||||
if keys_raw:
|
|
||||||
slugs = [k.strip() for k in keys_raw.split(",") if k.strip()]
|
|
||||||
parts = []
|
|
||||||
for s in slugs:
|
|
||||||
parts.append(f"<!-- fragment:{s} -->")
|
|
||||||
post = await services.blog.get_post_by_slug(g.s, s)
|
|
||||||
if post:
|
|
||||||
parts.append(_blog_link_card_sx(post, blog_url(f"/{post.slug}")))
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
# Single mode
|
|
||||||
if not slug:
|
|
||||||
return ""
|
|
||||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
|
||||||
if not post:
|
|
||||||
return ""
|
|
||||||
return _blog_link_card_sx(post, blog_url(f"/{post.slug}"))
|
|
||||||
|
|
||||||
_handlers["link-card"] = _link_card_handler
|
|
||||||
|
|
||||||
bp._fragment_handlers = _handlers
|
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from quart import Blueprint, render_template, make_response, request, jsonify, g
|
from quart import Blueprint, make_response, request, jsonify, g
|
||||||
|
|
||||||
from shared.browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from .services.menu_items import (
|
from .services.menu_items import (
|
||||||
@@ -23,34 +23,26 @@ def register():
|
|||||||
from sx.sx_components import render_menu_items_nav_oob
|
from sx.sx_components import render_menu_items_nav_oob
|
||||||
return render_menu_items_nav_oob(menu_items)
|
return render_menu_items_nav_oob(menu_items)
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.before_request
|
||||||
@require_admin
|
async def _prepare_page_data():
|
||||||
async def list_menu_items():
|
if "defpage_" not in (request.endpoint or ""):
|
||||||
"""List all menu items"""
|
return
|
||||||
menu_items = await get_all_menu_items(g.s)
|
menu_items = await get_all_menu_items(g.s)
|
||||||
|
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_menu_items_page, render_menu_items_oob
|
from sx.sx_components import _menu_items_main_panel_sx
|
||||||
|
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["menu_items"] = menu_items
|
tctx["menu_items"] = menu_items
|
||||||
if not is_htmx_request():
|
g.menu_items_content = _menu_items_main_panel_sx(tctx)
|
||||||
html = await render_menu_items_page(tctx)
|
|
||||||
return await make_response(html)
|
from shared.sx.pages import mount_pages
|
||||||
else:
|
mount_pages(bp, "blog", names=["menu-items-page"])
|
||||||
sx_src = await render_menu_items_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.get("/new/")
|
@bp.get("/new/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def new_menu_item():
|
async def new_menu_item():
|
||||||
"""Show form to create new menu item"""
|
"""Show form to create new menu item"""
|
||||||
html = await render_template(
|
from sx.sx_components import render_menu_item_form
|
||||||
"_types/menu_items/_form.html",
|
return sx_response(render_menu_item_form())
|
||||||
menu_item=None,
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
@bp.post("/")
|
@bp.post("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -89,11 +81,8 @@ def register():
|
|||||||
if not menu_item:
|
if not menu_item:
|
||||||
return await make_response("Menu item not found", 404)
|
return await make_response("Menu item not found", 404)
|
||||||
|
|
||||||
html = await render_template(
|
from sx.sx_components import render_menu_item_form
|
||||||
"_types/menu_items/_form.html",
|
return sx_response(render_menu_item_form(menu_item=menu_item))
|
||||||
menu_item=menu_item,
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
@bp.put("/<int:item_id>/")
|
@bp.put("/<int:item_id>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -153,14 +142,8 @@ def register():
|
|||||||
pages, total = await search_pages(g.s, query, page, per_page)
|
pages, total = await search_pages(g.s, query, page, per_page)
|
||||||
has_more = (page * per_page) < total
|
has_more = (page * per_page) < total
|
||||||
|
|
||||||
html = await render_template(
|
from sx.sx_components import render_page_search_results
|
||||||
"_types/menu_items/_page_search_results.html",
|
return sx_response(render_page_search_results(pages, query, page, has_more))
|
||||||
pages=pages,
|
|
||||||
query=query,
|
|
||||||
page=page,
|
|
||||||
has_more=has_more,
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
@bp.post("/reorder/")
|
@bp.post("/reorder/")
|
||||||
@require_admin
|
@require_admin
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
|
|
||||||
from quart import (
|
from quart import (
|
||||||
render_template,
|
|
||||||
make_response,
|
make_response,
|
||||||
Blueprint,
|
Blueprint,
|
||||||
g,
|
g,
|
||||||
@@ -15,54 +14,195 @@ from shared.browser.app.utils.htmx import is_htmx_request
|
|||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
from shared.utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
|
def _post_to_edit_dict(post) -> dict:
|
||||||
|
"""Convert an ORM Post to a dict matching the shape templates expect.
|
||||||
|
|
||||||
|
The templates were written for Ghost Admin API responses, so we mimic
|
||||||
|
that structure (dot-access on dicts via Jinja) from ORM columns.
|
||||||
|
"""
|
||||||
|
d: dict = {}
|
||||||
|
for col in (
|
||||||
|
"id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc",
|
||||||
|
"sx_content",
|
||||||
|
"feature_image", "feature_image_alt", "feature_image_caption",
|
||||||
|
"excerpt", "custom_excerpt", "visibility", "status", "featured",
|
||||||
|
"is_page", "email_only", "canonical_url",
|
||||||
|
"meta_title", "meta_description",
|
||||||
|
"og_image", "og_title", "og_description",
|
||||||
|
"twitter_image", "twitter_title", "twitter_description",
|
||||||
|
"custom_template", "reading_time", "comment_id",
|
||||||
|
):
|
||||||
|
d[col] = getattr(post, col, None)
|
||||||
|
|
||||||
|
# Timestamps as ISO strings (templates do [:16] slicing)
|
||||||
|
for ts in ("published_at", "updated_at", "created_at"):
|
||||||
|
val = getattr(post, ts, None)
|
||||||
|
d[ts] = val.isoformat() if val else ""
|
||||||
|
|
||||||
|
# Tags as list of dicts with .name (for Jinja map(attribute='name'))
|
||||||
|
if hasattr(post, "tags") and post.tags:
|
||||||
|
d["tags"] = [{"name": t.name, "slug": t.slug, "id": t.id} for t in post.tags]
|
||||||
|
else:
|
||||||
|
d["tags"] = []
|
||||||
|
|
||||||
|
# email/newsletter — not available without Ghost, set safe defaults
|
||||||
|
d["email"] = None
|
||||||
|
d["newsletter"] = None
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
@bp.get("/")
|
async def _prepare_page_data():
|
||||||
@require_admin
|
ep = request.endpoint or ""
|
||||||
async def admin(slug: str):
|
if "defpage_post_admin" in ep:
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from sqlalchemy import select
|
||||||
from sqlalchemy import select
|
from shared.models.page_config import PageConfig
|
||||||
from shared.models.page_config import PageConfig
|
post = (g.post_data or {}).get("post", {})
|
||||||
|
features = {}
|
||||||
|
sumup_configured = False
|
||||||
|
sumup_merchant_code = ""
|
||||||
|
sumup_checkout_prefix = ""
|
||||||
|
if post.get("is_page"):
|
||||||
|
pc = (await g.s.execute(
|
||||||
|
select(PageConfig).where(
|
||||||
|
PageConfig.container_type == "page",
|
||||||
|
PageConfig.container_id == post["id"],
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if pc:
|
||||||
|
features = pc.features or {}
|
||||||
|
sumup_configured = bool(pc.sumup_api_key)
|
||||||
|
sumup_merchant_code = pc.sumup_merchant_code or ""
|
||||||
|
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _post_admin_main_panel_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update({
|
||||||
|
"features": features,
|
||||||
|
"sumup_configured": sumup_configured,
|
||||||
|
"sumup_merchant_code": sumup_merchant_code,
|
||||||
|
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||||
|
})
|
||||||
|
g.post_admin_content = _post_admin_main_panel_sx(tctx)
|
||||||
|
|
||||||
# Load features for page admin (page_configs now lives in db_blog)
|
elif "defpage_post_data" in ep:
|
||||||
post = (g.post_data or {}).get("post", {})
|
from shared.sx.page import get_template_context
|
||||||
features = {}
|
from sx.sx_components import _post_data_content_sx
|
||||||
sumup_configured = False
|
tctx = await get_template_context()
|
||||||
sumup_merchant_code = ""
|
g.post_data_content = _post_data_content_sx(tctx)
|
||||||
sumup_checkout_prefix = ""
|
|
||||||
if post.get("is_page"):
|
|
||||||
pc = (await g.s.execute(
|
|
||||||
select(PageConfig).where(
|
|
||||||
PageConfig.container_type == "page",
|
|
||||||
PageConfig.container_id == post["id"],
|
|
||||||
)
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
if pc:
|
|
||||||
features = pc.features or {}
|
|
||||||
sumup_configured = bool(pc.sumup_api_key)
|
|
||||||
sumup_merchant_code = pc.sumup_merchant_code or ""
|
|
||||||
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
|
||||||
|
|
||||||
ctx = {
|
elif "defpage_post_preview" in ep:
|
||||||
"features": features,
|
from models.ghost_content import Post
|
||||||
"sumup_configured": sumup_configured,
|
from sqlalchemy import select as sa_select
|
||||||
"sumup_merchant_code": sumup_merchant_code,
|
post_id = g.post_data["post"]["id"]
|
||||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
post = (await g.s.execute(
|
||||||
}
|
sa_select(Post).where(Post.id == post_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
preview_ctx = {}
|
||||||
|
sx_content = getattr(post, "sx_content", None) or ""
|
||||||
|
if sx_content:
|
||||||
|
from shared.sx.prettify import sx_to_pretty_sx
|
||||||
|
preview_ctx["sx_pretty"] = sx_to_pretty_sx(sx_content)
|
||||||
|
lexical_raw = getattr(post, "lexical", None) or ""
|
||||||
|
if lexical_raw:
|
||||||
|
from shared.sx.prettify import json_to_pretty_sx
|
||||||
|
preview_ctx["json_pretty"] = json_to_pretty_sx(lexical_raw)
|
||||||
|
if sx_content:
|
||||||
|
from shared.sx.parser import parse as sx_parse
|
||||||
|
from shared.sx.html import render as sx_html_render
|
||||||
|
from shared.sx.jinja_bridge import _COMPONENT_ENV
|
||||||
|
try:
|
||||||
|
parsed = sx_parse(sx_content)
|
||||||
|
preview_ctx["sx_rendered"] = sx_html_render(parsed, dict(_COMPONENT_ENV))
|
||||||
|
except Exception:
|
||||||
|
preview_ctx["sx_rendered"] = "<em>Error rendering sx</em>"
|
||||||
|
if lexical_raw:
|
||||||
|
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||||
|
try:
|
||||||
|
preview_ctx["lex_rendered"] = render_lexical(lexical_raw)
|
||||||
|
except Exception:
|
||||||
|
preview_ctx["lex_rendered"] = "<em>Error rendering lexical</em>"
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _preview_main_panel_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(preview_ctx)
|
||||||
|
g.post_preview_content = _preview_main_panel_sx(tctx)
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
elif "defpage_post_entries" in ep:
|
||||||
from sx.sx_components import render_post_admin_page, render_post_admin_oob
|
from sqlalchemy import select
|
||||||
|
from shared.models.calendars import Calendar
|
||||||
|
from ..services.entry_associations import get_post_entry_ids
|
||||||
|
post_id = g.post_data["post"]["id"]
|
||||||
|
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||||
|
result = await g.s.execute(
|
||||||
|
select(Calendar)
|
||||||
|
.where(Calendar.deleted_at.is_(None))
|
||||||
|
.order_by(Calendar.name.asc())
|
||||||
|
)
|
||||||
|
all_calendars = result.scalars().all()
|
||||||
|
for calendar in all_calendars:
|
||||||
|
await g.s.refresh(calendar, ["entries", "post"])
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _post_entries_content_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["all_calendars"] = all_calendars
|
||||||
|
tctx["associated_entry_ids"] = associated_entry_ids
|
||||||
|
g.post_entries_content = _post_entries_content_sx(tctx)
|
||||||
|
|
||||||
tctx = await get_template_context()
|
elif "defpage_post_settings" in ep:
|
||||||
tctx.update(ctx)
|
from models.ghost_content import Post
|
||||||
if not is_htmx_request():
|
from sqlalchemy import select as sa_select
|
||||||
html = await render_post_admin_page(tctx)
|
from sqlalchemy.orm import selectinload
|
||||||
return await make_response(html)
|
post_id = g.post_data["post"]["id"]
|
||||||
else:
|
post = (await g.s.execute(
|
||||||
sx_src = await render_post_admin_oob(tctx)
|
sa_select(Post)
|
||||||
return sx_response(sx_src)
|
.where(Post.id == post_id)
|
||||||
|
.options(selectinload(Post.tags))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||||
|
save_success = request.args.get("saved") == "1"
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _post_settings_content_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["ghost_post"] = ghost_post
|
||||||
|
tctx["save_success"] = save_success
|
||||||
|
g.post_settings_content = _post_settings_content_sx(tctx)
|
||||||
|
|
||||||
|
elif "defpage_post_edit" in ep:
|
||||||
|
from models.ghost_content import Post
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from shared.infrastructure.data_client import fetch_data
|
||||||
|
post_id = g.post_data["post"]["id"]
|
||||||
|
post = (await g.s.execute(
|
||||||
|
sa_select(Post)
|
||||||
|
.where(Post.id == post_id)
|
||||||
|
.options(selectinload(Post.tags))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
ghost_post = _post_to_edit_dict(post) if post else {}
|
||||||
|
save_success = request.args.get("saved") == "1"
|
||||||
|
save_error = request.args.get("error", "")
|
||||||
|
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
||||||
|
from types import SimpleNamespace
|
||||||
|
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||||
|
from shared.sx.page import get_template_context
|
||||||
|
from sx.sx_components import _post_edit_content_sx
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["ghost_post"] = ghost_post
|
||||||
|
tctx["save_success"] = save_success
|
||||||
|
tctx["save_error"] = save_error
|
||||||
|
tctx["newsletters"] = newsletters
|
||||||
|
g.post_edit_content = _post_edit_content_sx(tctx)
|
||||||
|
|
||||||
|
from shared.sx.pages import mount_pages
|
||||||
|
mount_pages(bp, "blog", names=[
|
||||||
|
"post-admin", "post-data", "post-preview",
|
||||||
|
"post-entries", "post-settings", "post-edit",
|
||||||
|
])
|
||||||
|
|
||||||
@bp.put("/features/")
|
@bp.put("/features/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -147,22 +287,6 @@ def register():
|
|||||||
)
|
)
|
||||||
return sx_response(html)
|
return sx_response(html)
|
||||||
|
|
||||||
@bp.get("/data/")
|
|
||||||
@require_admin
|
|
||||||
async def data(slug: str):
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_post_data_page, render_post_data_oob
|
|
||||||
|
|
||||||
data_html = await render_template("_types/post_data/_main_panel.html")
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx["data_html"] = data_html
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_post_data_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_post_data_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.get("/entries/calendar/<int:calendar_id>/")
|
@bp.get("/entries/calendar/<int:calendar_id>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def calendar_view(slug: str, calendar_id: int):
|
async def calendar_view(slug: str, calendar_id: int):
|
||||||
@@ -227,64 +351,16 @@ def register():
|
|||||||
|
|
||||||
# Get associated entry IDs for this post
|
# Get associated entry IDs for this post
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||||
|
|
||||||
html = await render_template(
|
from sx.sx_components import render_calendar_view
|
||||||
"_types/post/admin/_calendar_view.html",
|
html = render_calendar_view(
|
||||||
calendar=calendar_obj,
|
calendar_obj, year, month, month_name, weekday_names, weeks,
|
||||||
year=year,
|
prev_month, prev_month_year, next_month, next_month_year,
|
||||||
month=month,
|
prev_year, next_year, month_entries, associated_entry_ids,
|
||||||
month_name=month_name,
|
g.post_data["post"]["slug"],
|
||||||
weekday_names=weekday_names,
|
|
||||||
weeks=weeks,
|
|
||||||
prev_month=prev_month,
|
|
||||||
prev_month_year=prev_month_year,
|
|
||||||
next_month=next_month,
|
|
||||||
next_month_year=next_month_year,
|
|
||||||
prev_year=prev_year,
|
|
||||||
next_year=next_year,
|
|
||||||
month_entries=month_entries,
|
|
||||||
associated_entry_ids=associated_entry_ids,
|
|
||||||
)
|
)
|
||||||
return await make_response(html)
|
return sx_response(html)
|
||||||
|
|
||||||
@bp.get("/entries/")
|
|
||||||
@require_admin
|
|
||||||
async def entries(slug: str):
|
|
||||||
from ..services.entry_associations import get_post_entry_ids
|
|
||||||
from shared.models.calendars import Calendar
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
|
||||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
|
||||||
|
|
||||||
# Load ALL calendars (not just this post's calendars)
|
|
||||||
result = await g.s.execute(
|
|
||||||
select(Calendar)
|
|
||||||
.where(Calendar.deleted_at.is_(None))
|
|
||||||
.order_by(Calendar.name.asc())
|
|
||||||
)
|
|
||||||
all_calendars = result.scalars().all()
|
|
||||||
|
|
||||||
# Load entries and post for each calendar
|
|
||||||
for calendar in all_calendars:
|
|
||||||
await g.s.refresh(calendar, ["entries", "post"])
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_post_entries_page, render_post_entries_oob
|
|
||||||
|
|
||||||
entries_html = await render_template(
|
|
||||||
"_types/post_entries/_main_panel.html",
|
|
||||||
all_calendars=all_calendars,
|
|
||||||
associated_entry_ids=associated_entry_ids,
|
|
||||||
)
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx["entries_html"] = entries_html
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_post_entries_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_post_entries_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.post("/entries/<int:entry_id>/toggle/")
|
@bp.post("/entries/<int:entry_id>/toggle/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -295,7 +371,7 @@ def register():
|
|||||||
from quart import jsonify
|
from quart import jsonify
|
||||||
|
|
||||||
post_id = g.post_data["post"]["id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
is_associated, error = await toggle_entry_association(g.s, post_id, entry_id)
|
is_associated, error = await toggle_entry_association(post_id, entry_id)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
return jsonify({"message": error, "errors": {}}), 400
|
return jsonify({"message": error, "errors": {}}), 400
|
||||||
@@ -303,7 +379,7 @@ def register():
|
|||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
# Return updated association status
|
# Return updated association status
|
||||||
associated_entry_ids = await get_post_entry_ids(g.s, post_id)
|
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||||
|
|
||||||
# Load ALL calendars
|
# Load ALL calendars
|
||||||
result = await g.s.execute(
|
result = await g.s.execute(
|
||||||
@@ -318,7 +394,7 @@ def register():
|
|||||||
await g.s.refresh(calendar, ["entries", "post"])
|
await g.s.refresh(calendar, ["entries", "post"])
|
||||||
|
|
||||||
# Fetch associated entries for nav display
|
# Fetch associated entries for nav display
|
||||||
associated_entries = await get_associated_entries(g.s, post_id)
|
associated_entries = await get_associated_entries(post_id)
|
||||||
|
|
||||||
# Load calendars for this post (for nav display)
|
# Load calendars for this post (for nav display)
|
||||||
calendars = (
|
calendars = (
|
||||||
@@ -338,42 +414,13 @@ def register():
|
|||||||
|
|
||||||
return sx_response(admin_list + nav_entries_html)
|
return sx_response(admin_list + nav_entries_html)
|
||||||
|
|
||||||
@bp.get("/settings/")
|
|
||||||
@require_post_author
|
|
||||||
async def settings(slug: str):
|
|
||||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
|
||||||
is_page = bool(g.post_data["post"].get("is_page"))
|
|
||||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
|
||||||
save_success = request.args.get("saved") == "1"
|
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_post_settings_page, render_post_settings_oob
|
|
||||||
|
|
||||||
settings_html = await render_template(
|
|
||||||
"_types/post_settings/_main_panel.html",
|
|
||||||
ghost_post=ghost_post,
|
|
||||||
save_success=save_success,
|
|
||||||
)
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx["settings_html"] = settings_html
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_post_settings_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_post_settings_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.post("/settings/")
|
@bp.post("/settings/")
|
||||||
@require_post_author
|
@require_post_author
|
||||||
async def settings_save(slug: str):
|
async def settings_save(slug: str):
|
||||||
from ...blog.ghost.ghost_posts import update_post_settings
|
from services.post_writer import update_post_settings, OptimisticLockError
|
||||||
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
|
|
||||||
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
is_page = bool(g.post_data["post"].get("is_page"))
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
|
|
||||||
updated_at = form.get("updated_at", "")
|
updated_at = form.get("updated_at", "")
|
||||||
@@ -406,89 +453,48 @@ def register():
|
|||||||
kwargs["featured"] = form.get("featured") == "on"
|
kwargs["featured"] = form.get("featured") == "on"
|
||||||
kwargs["email_only"] = form.get("email_only") == "on"
|
kwargs["email_only"] = form.get("email_only") == "on"
|
||||||
|
|
||||||
# Tags — comma-separated string → list of {"name": "..."} dicts
|
# Tags — comma-separated string → list of names
|
||||||
tags_str = form.get("tags", "").strip()
|
tags_str = form.get("tags", "").strip()
|
||||||
if tags_str:
|
tag_names = [t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else []
|
||||||
kwargs["tags"] = [{"name": t.strip()} for t in tags_str.split(",") if t.strip()]
|
|
||||||
else:
|
|
||||||
kwargs["tags"] = []
|
|
||||||
|
|
||||||
# Update in Ghost
|
try:
|
||||||
await update_post_settings(
|
post = await update_post_settings(
|
||||||
ghost_id=ghost_id,
|
g.s,
|
||||||
updated_at=updated_at,
|
post_id=post_id,
|
||||||
is_page=is_page,
|
expected_updated_at=updated_at,
|
||||||
**kwargs,
|
tag_names=tag_names,
|
||||||
)
|
**kwargs,
|
||||||
|
)
|
||||||
|
except OptimisticLockError:
|
||||||
|
from urllib.parse import quote
|
||||||
|
return redirect(
|
||||||
|
host_url(url_for("blog.post.admin.defpage_post_settings", slug=slug))
|
||||||
|
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||||
|
)
|
||||||
|
|
||||||
# Sync to local DB
|
|
||||||
if is_page:
|
|
||||||
await sync_single_page(g.s, ghost_id)
|
|
||||||
else:
|
|
||||||
await sync_single_post(g.s, ghost_id)
|
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
# Clear caches
|
# Clear caches
|
||||||
await invalidate_tag_cache("blog")
|
await invalidate_tag_cache("blog")
|
||||||
await invalidate_tag_cache("post.post_detail")
|
await invalidate_tag_cache("post.post_detail")
|
||||||
|
|
||||||
return redirect(host_url(url_for("blog.post.admin.settings", slug=slug)) + "?saved=1")
|
# Redirect using the (possibly new) slug
|
||||||
|
return redirect(host_url(url_for("blog.post.admin.defpage_post_settings", slug=post.slug)) + "?saved=1")
|
||||||
@bp.get("/edit/")
|
|
||||||
@require_post_author
|
|
||||||
async def edit(slug: str):
|
|
||||||
from ...blog.ghost.ghost_posts import get_post_for_edit
|
|
||||||
from shared.infrastructure.data_client import fetch_data
|
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
|
||||||
is_page = bool(g.post_data["post"].get("is_page"))
|
|
||||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
|
||||||
save_success = request.args.get("saved") == "1"
|
|
||||||
save_error = request.args.get("error", "")
|
|
||||||
|
|
||||||
# Newsletters live in db_account — fetch via HTTP
|
|
||||||
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
|
|
||||||
# Convert dicts to objects with .name/.ghost_id attributes for template compat
|
|
||||||
from types import SimpleNamespace
|
|
||||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
|
||||||
from sx.sx_components import render_post_edit_page, render_post_edit_oob
|
|
||||||
|
|
||||||
edit_html = await render_template(
|
|
||||||
"_types/post_edit/_main_panel.html",
|
|
||||||
ghost_post=ghost_post,
|
|
||||||
save_success=save_success,
|
|
||||||
save_error=save_error,
|
|
||||||
newsletters=newsletters,
|
|
||||||
)
|
|
||||||
tctx = await get_template_context()
|
|
||||||
tctx["edit_html"] = edit_html
|
|
||||||
if not is_htmx_request():
|
|
||||||
html = await render_post_edit_page(tctx)
|
|
||||||
return await make_response(html)
|
|
||||||
else:
|
|
||||||
sx_src = await render_post_edit_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.post("/edit/")
|
@bp.post("/edit/")
|
||||||
@require_post_author
|
@require_post_author
|
||||||
async def edit_save(slug: str):
|
async def edit_save(slug: str):
|
||||||
import json
|
import json
|
||||||
from ...blog.ghost.ghost_posts import update_post
|
|
||||||
from ...blog.ghost.lexical_validator import validate_lexical
|
from ...blog.ghost.lexical_validator import validate_lexical
|
||||||
from ...blog.ghost.ghost_sync import sync_single_post, sync_single_page
|
from services.post_writer import update_post as writer_update, OptimisticLockError
|
||||||
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||||
|
|
||||||
ghost_id = g.post_data["post"]["ghost_id"]
|
post_id = g.post_data["post"]["id"]
|
||||||
is_page = bool(g.post_data["post"].get("is_page"))
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
title = form.get("title", "").strip()
|
title = form.get("title", "").strip()
|
||||||
lexical_raw = form.get("lexical", "")
|
lexical_raw = form.get("lexical", "")
|
||||||
updated_at = form.get("updated_at", "")
|
updated_at = form.get("updated_at", "")
|
||||||
status = form.get("status", "draft")
|
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()
|
feature_image = form.get("feature_image", "").strip()
|
||||||
custom_excerpt = form.get("custom_excerpt", "").strip()
|
custom_excerpt = form.get("custom_excerpt", "").strip()
|
||||||
feature_image_caption = form.get("feature_image_caption", "").strip()
|
feature_image_caption = form.get("feature_image_caption", "").strip()
|
||||||
@@ -498,82 +504,63 @@ def register():
|
|||||||
try:
|
try:
|
||||||
lexical_doc = json.loads(lexical_raw)
|
lexical_doc = json.loads(lexical_raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||||
|
|
||||||
ok, reason = validate_lexical(lexical_doc)
|
ok, reason = validate_lexical(lexical_doc)
|
||||||
if not ok:
|
if not ok:
|
||||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
|
return redirect(host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
|
||||||
|
|
||||||
# Update in Ghost (content save — no status change yet)
|
|
||||||
ghost_post = await update_post(
|
|
||||||
ghost_id=ghost_id,
|
|
||||||
lexical_json=lexical_raw,
|
|
||||||
title=title or None,
|
|
||||||
updated_at=updated_at,
|
|
||||||
feature_image=feature_image,
|
|
||||||
custom_excerpt=custom_excerpt,
|
|
||||||
feature_image_caption=feature_image_caption,
|
|
||||||
is_page=is_page,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Publish workflow
|
# Publish workflow
|
||||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||||
publish_requested_msg = None
|
publish_requested_msg = None
|
||||||
|
|
||||||
# Guard: if already emailed, force publish_mode to "web" to prevent re-send
|
# Determine effective status
|
||||||
already_emailed = bool(ghost_post.get("email") and ghost_post["email"].get("status"))
|
effective_status: str | None = None
|
||||||
if already_emailed and publish_mode in ("email", "both"):
|
current_status = g.post_data["post"].get("status", "draft")
|
||||||
publish_mode = "web"
|
|
||||||
|
|
||||||
if status == "published" and ghost_post.get("status") != "published" and not is_admin:
|
if status == "published" and current_status != "published" and not is_admin:
|
||||||
# Non-admin requesting publish: don't send status to Ghost, set local flag
|
# Non-admin requesting publish: keep as draft, set local flag
|
||||||
publish_requested_msg = "Publish requested — an admin will review."
|
publish_requested_msg = "Publish requested — an admin will review."
|
||||||
elif status and status != ghost_post.get("status"):
|
elif status and status != current_status:
|
||||||
# Status is changing — determine email params based on publish_mode
|
effective_status = status
|
||||||
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
|
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||||
ghost_post = await _up(
|
# Build optional kwargs — only pass sx_content if the form field was present
|
||||||
ghost_id=ghost_id,
|
extra_kw: dict = {}
|
||||||
|
if "sx_content" in form:
|
||||||
|
extra_kw["sx_content"] = sx_content_raw
|
||||||
|
try:
|
||||||
|
post = await writer_update(
|
||||||
|
g.s,
|
||||||
|
post_id=post_id,
|
||||||
lexical_json=lexical_raw,
|
lexical_json=lexical_raw,
|
||||||
title=None,
|
title=title or None,
|
||||||
updated_at=ghost_post["updated_at"],
|
expected_updated_at=updated_at,
|
||||||
status=status,
|
feature_image=feature_image or None,
|
||||||
is_page=is_page,
|
custom_excerpt=custom_excerpt or None,
|
||||||
**email_kwargs,
|
feature_image_caption=feature_image_caption or None,
|
||||||
|
status=effective_status,
|
||||||
|
**extra_kw,
|
||||||
|
)
|
||||||
|
except OptimisticLockError:
|
||||||
|
return redirect(
|
||||||
|
host_url(url_for("blog.post.admin.defpage_post_edit", slug=slug))
|
||||||
|
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync to local DB
|
# Handle publish_requested flag
|
||||||
if is_page:
|
if publish_requested_msg:
|
||||||
await sync_single_page(g.s, ghost_id)
|
post.publish_requested = True
|
||||||
else:
|
elif status == "published" and is_admin:
|
||||||
await sync_single_post(g.s, ghost_id)
|
post.publish_requested = False
|
||||||
await g.s.flush()
|
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
|
# Clear caches
|
||||||
await invalidate_tag_cache("blog")
|
await invalidate_tag_cache("blog")
|
||||||
await invalidate_tag_cache("post.post_detail")
|
await invalidate_tag_cache("post.post_detail")
|
||||||
|
|
||||||
# Redirect to GET to avoid resubmit warning on refresh (PRG pattern)
|
# Redirect to GET (PRG pattern) — use post.slug in case it changed
|
||||||
redirect_url = host_url(url_for("blog.post.admin.edit", slug=slug)) + "?saved=1"
|
redirect_url = host_url(url_for("blog.post.admin.defpage_post_edit", slug=post.slug)) + "?saved=1"
|
||||||
if publish_requested_msg:
|
if publish_requested_msg:
|
||||||
redirect_url += "&publish_requested=1"
|
redirect_url += "&publish_requested=1"
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from shared.contracts.dtos import MarketPlaceDTO
|
from shared.contracts.dtos import MarketPlaceDTO
|
||||||
from shared.infrastructure.actions import call_action, ActionError
|
from shared.infrastructure.actions import call_action, ActionError
|
||||||
from shared.services.registry import services
|
from services import blog_service
|
||||||
|
|
||||||
|
|
||||||
class MarketError(ValueError):
|
class MarketError(ValueError):
|
||||||
@@ -33,7 +33,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
|||||||
raise MarketError("Market name must not be empty.")
|
raise MarketError("Market name must not be empty.")
|
||||||
slug = slugify(name)
|
slug = slugify(name)
|
||||||
|
|
||||||
post = await services.blog.get_post_by_id(sess, post_id)
|
post = await blog_service.get_post_by_id(sess, post_id)
|
||||||
if not post:
|
if not post:
|
||||||
raise MarketError(f"Post {post_id} does not exist.")
|
raise MarketError(f"Post {post_id} does not exist.")
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
|||||||
|
|
||||||
|
|
||||||
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
post = await blog_service.get_post_by_slug(sess, post_slug)
|
||||||
if not post:
|
if not post:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -32,25 +32,21 @@ async def _visible_snippets(session):
|
|||||||
def register():
|
def register():
|
||||||
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
|
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.before_request
|
||||||
@require_login
|
async def _prepare_page_data():
|
||||||
async def list_snippets():
|
if "defpage_" not in (request.endpoint or ""):
|
||||||
"""List snippets visible to the current user."""
|
return
|
||||||
snippets = await _visible_snippets(g.s)
|
snippets = await _visible_snippets(g.s)
|
||||||
is_admin = g.rights.get("admin")
|
is_admin = g.rights.get("admin")
|
||||||
|
|
||||||
from shared.sx.page import get_template_context
|
from shared.sx.page import get_template_context
|
||||||
from sx.sx_components import render_snippets_page, render_snippets_oob
|
from sx.sx_components import _snippets_main_panel_sx
|
||||||
|
|
||||||
tctx = await get_template_context()
|
tctx = await get_template_context()
|
||||||
tctx["snippets"] = snippets
|
tctx["snippets"] = snippets
|
||||||
tctx["is_admin"] = is_admin
|
tctx["is_admin"] = is_admin
|
||||||
if not is_htmx_request():
|
g.snippets_content = _snippets_main_panel_sx(tctx)
|
||||||
html = await render_snippets_page(tctx)
|
|
||||||
return await make_response(html)
|
from shared.sx.pages import mount_pages
|
||||||
else:
|
mount_pages(bp, "blog", names=["snippets-page"])
|
||||||
sx_src = await render_snippets_oob(tctx)
|
|
||||||
return sx_response(sx_src)
|
|
||||||
|
|
||||||
@bp.delete("/<int:snippet_id>/")
|
@bp.delete("/<int:snippet_id>/")
|
||||||
@require_login
|
@require_login
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag
|
from .content import Post, Author, Tag, PostAuthor, PostTag, PostUser
|
||||||
from .snippet import Snippet
|
from .snippet import Snippet
|
||||||
from .tag_group import TagGroup, TagGroupTag
|
from .tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Tag(Base):
|
|||||||
__tablename__ = "tags"
|
__tablename__ = "tags"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||||
|
|
||||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
@@ -50,8 +50,8 @@ class Post(Base):
|
|||||||
__tablename__ = "posts"
|
__tablename__ = "posts"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||||
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
uuid: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, server_default=func.gen_random_uuid())
|
||||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||||
|
|
||||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
@@ -60,6 +60,7 @@ class Post(Base):
|
|||||||
plaintext: Mapped[Optional[str]] = mapped_column(Text())
|
plaintext: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
mobiledoc: Mapped[Optional[str]] = mapped_column(Text())
|
mobiledoc: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
lexical: Mapped[Optional[str]] = mapped_column(Text())
|
lexical: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
|
sx_content: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
|
|
||||||
feature_image: Mapped[Optional[str]] = mapped_column(Text())
|
feature_image: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
feature_image_alt: Mapped[Optional[str]] = mapped_column(Text())
|
feature_image_alt: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
@@ -89,8 +90,8 @@ class Post(Base):
|
|||||||
comment_id: Mapped[Optional[str]] = mapped_column(String(191))
|
comment_id: Mapped[Optional[str]] = mapped_column(String(191))
|
||||||
|
|
||||||
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
user_id: Mapped[Optional[int]] = mapped_column(
|
user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
@@ -136,7 +137,7 @@ class Author(Base):
|
|||||||
__tablename__ = "authors"
|
__tablename__ = "authors"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
ghost_id: Mapped[str] = mapped_column(String(64), index=True, unique=True, nullable=False)
|
ghost_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, unique=True, nullable=True)
|
||||||
|
|
||||||
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
slug: Mapped[str] = mapped_column(String(191), index=True, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
@@ -192,3 +193,15 @@ class PostTag(Base):
|
|||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PostUser(Base):
|
||||||
|
"""Multi-author M2M: links posts to users (cross-DB, no FK on user_id)."""
|
||||||
|
__tablename__ = "post_users"
|
||||||
|
|
||||||
|
post_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("posts.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, primary_key=True, index=True,
|
||||||
|
)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
from shared.models.ghost_content import ( # noqa: F401
|
from .content import ( # noqa: F401
|
||||||
Tag, Post, Author, PostAuthor, PostTag,
|
Tag, Post, Author, PostAuthor, PostTag, PostUser,
|
||||||
)
|
)
|
||||||
|
|||||||
68
blog/scripts/backfill_sx_content.py
Normal file
68
blog/scripts/backfill_sx_content.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Backfill sx_content from lexical JSON for all posts that have lexical but no sx_content.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m blog.scripts.backfill_sx_content [--dry-run]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill(dry_run: bool = False) -> int:
|
||||||
|
from shared.db.session import get_session
|
||||||
|
from models.ghost_content import Post
|
||||||
|
from bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
||||||
|
|
||||||
|
converted = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
async with get_session() as sess:
|
||||||
|
stmt = select(Post).where(
|
||||||
|
and_(
|
||||||
|
Post.lexical.isnot(None),
|
||||||
|
Post.lexical != "",
|
||||||
|
(Post.sx_content.is_(None)) | (Post.sx_content == ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await sess.execute(stmt)
|
||||||
|
posts = result.scalars().all()
|
||||||
|
|
||||||
|
print(f"Found {len(posts)} posts to convert")
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
try:
|
||||||
|
sx = lexical_to_sx(post.lexical)
|
||||||
|
if dry_run:
|
||||||
|
print(f" [DRY RUN] {post.slug}: {len(sx)} chars")
|
||||||
|
else:
|
||||||
|
post.sx_content = sx
|
||||||
|
print(f" Converted: {post.slug} ({len(sx)} chars)")
|
||||||
|
converted += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
await sess.commit()
|
||||||
|
|
||||||
|
print(f"\nDone: {converted} converted, {errors} errors")
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Backfill sx_content from lexical JSON")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Don't write to database")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
asyncio.run(backfill(dry_run=args.dry_run))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
469
blog/scripts/final_ghost_sync.py
Normal file
469
blog/scripts/final_ghost_sync.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Final Ghost → db_blog sync with HTML verification + author→user migration.
|
||||||
|
|
||||||
|
Run once before cutting over to native writes (Phase 1).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd blog && python -m scripts.final_ghost_sync
|
||||||
|
|
||||||
|
Requires GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY, DATABASE_URL,
|
||||||
|
and DATABASE_URL_ACCOUNT env vars.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import difflib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select, func, delete
|
||||||
|
|
||||||
|
# Ensure project root is importable
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "shared"))
|
||||||
|
|
||||||
|
from shared.db.base import Base # noqa: E402
|
||||||
|
from shared.db.session import get_session, get_account_session, _engine # noqa: E402
|
||||||
|
from shared.infrastructure.ghost_admin_token import make_ghost_admin_jwt # noqa: E402
|
||||||
|
from blog.models.content import Post, Author, Tag, PostUser, PostAuthor # noqa: E402
|
||||||
|
from shared.models.user import User # noqa: E402
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("final_ghost_sync")
|
||||||
|
|
||||||
|
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 _slugify(name: str) -> str:
|
||||||
|
s = name.strip().lower()
|
||||||
|
s = re.sub(r"[^\w\s-]", "", s)
|
||||||
|
s = re.sub(r"[\s_]+", "-", s)
|
||||||
|
return s.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ghost API fetch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _fetch_all(endpoint: str) -> list[dict]:
|
||||||
|
"""Fetch all items from a Ghost Admin API endpoint."""
|
||||||
|
url = (
|
||||||
|
f"{GHOST_ADMIN_API_URL}/{endpoint}/"
|
||||||
|
"?include=authors,tags&limit=all"
|
||||||
|
"&formats=html,plaintext,mobiledoc,lexical"
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.get(url, headers=_auth_header())
|
||||||
|
resp.raise_for_status()
|
||||||
|
key = endpoint # "posts" or "pages"
|
||||||
|
return resp.json().get(key, [])
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_all_ghost_content() -> list[dict]:
|
||||||
|
"""Fetch all posts + pages from Ghost."""
|
||||||
|
posts, pages = await asyncio.gather(
|
||||||
|
_fetch_all("posts"),
|
||||||
|
_fetch_all("pages"),
|
||||||
|
)
|
||||||
|
for p in pages:
|
||||||
|
p["page"] = True
|
||||||
|
return posts + pages
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Author → User migration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def migrate_authors_to_users(author_bucket: dict[str, dict]) -> dict[str, int]:
|
||||||
|
"""Ensure every Ghost author has a corresponding User in db_account.
|
||||||
|
|
||||||
|
Returns mapping of ghost_author_id → user_id.
|
||||||
|
"""
|
||||||
|
ghost_id_to_user_id: dict[str, int] = {}
|
||||||
|
|
||||||
|
async with get_account_session() as sess:
|
||||||
|
async with sess.begin():
|
||||||
|
for ghost_author_id, ga in author_bucket.items():
|
||||||
|
email = (ga.get("email") or "").strip().lower()
|
||||||
|
if not email:
|
||||||
|
log.warning(
|
||||||
|
"Author %s (%s) has no email — skipping user creation",
|
||||||
|
ghost_author_id, ga.get("name"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find existing user by email
|
||||||
|
user = (await sess.execute(
|
||||||
|
select(User).where(User.email == email)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
# Auto-create user for this Ghost author
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
name=ga.get("name"),
|
||||||
|
slug=ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0]),
|
||||||
|
bio=ga.get("bio"),
|
||||||
|
profile_image=ga.get("profile_image"),
|
||||||
|
cover_image=ga.get("cover_image"),
|
||||||
|
website=ga.get("website"),
|
||||||
|
location=ga.get("location"),
|
||||||
|
facebook=ga.get("facebook"),
|
||||||
|
twitter=ga.get("twitter"),
|
||||||
|
is_admin=True, # Ghost authors are admins
|
||||||
|
)
|
||||||
|
sess.add(user)
|
||||||
|
await sess.flush()
|
||||||
|
log.info("Created user %d for author %s (%s)", user.id, ga.get("name"), email)
|
||||||
|
else:
|
||||||
|
# Update profile fields from Ghost author (fill in blanks)
|
||||||
|
if not user.slug:
|
||||||
|
user.slug = ga.get("slug") or _slugify(ga.get("name") or email.split("@")[0])
|
||||||
|
if not user.name and ga.get("name"):
|
||||||
|
user.name = ga["name"]
|
||||||
|
if not user.bio and ga.get("bio"):
|
||||||
|
user.bio = ga["bio"]
|
||||||
|
if not user.profile_image and ga.get("profile_image"):
|
||||||
|
user.profile_image = ga["profile_image"]
|
||||||
|
if not user.cover_image and ga.get("cover_image"):
|
||||||
|
user.cover_image = ga["cover_image"]
|
||||||
|
if not user.website and ga.get("website"):
|
||||||
|
user.website = ga["website"]
|
||||||
|
if not user.location and ga.get("location"):
|
||||||
|
user.location = ga["location"]
|
||||||
|
if not user.facebook and ga.get("facebook"):
|
||||||
|
user.facebook = ga["facebook"]
|
||||||
|
if not user.twitter and ga.get("twitter"):
|
||||||
|
user.twitter = ga["twitter"]
|
||||||
|
await sess.flush()
|
||||||
|
log.info("Updated user %d profile from author %s", user.id, ga.get("name"))
|
||||||
|
|
||||||
|
ghost_id_to_user_id[ghost_author_id] = user.id
|
||||||
|
|
||||||
|
return ghost_id_to_user_id
|
||||||
|
|
||||||
|
|
||||||
|
async def populate_post_users(
|
||||||
|
data: list[dict],
|
||||||
|
ghost_author_to_user: dict[str, int],
|
||||||
|
) -> int:
|
||||||
|
"""Populate post_users M2M and set user_id on all posts from author mapping.
|
||||||
|
|
||||||
|
Returns number of post_users rows created.
|
||||||
|
"""
|
||||||
|
rows_created = 0
|
||||||
|
|
||||||
|
async with get_session() as sess:
|
||||||
|
async with sess.begin():
|
||||||
|
for gp in data:
|
||||||
|
ghost_post_id = gp["id"]
|
||||||
|
post = (await sess.execute(
|
||||||
|
select(Post).where(Post.ghost_id == ghost_post_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not post:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Set primary user_id from primary author
|
||||||
|
pa = gp.get("primary_author")
|
||||||
|
if pa and pa["id"] in ghost_author_to_user:
|
||||||
|
post.user_id = ghost_author_to_user[pa["id"]]
|
||||||
|
|
||||||
|
# Build post_users from all authors
|
||||||
|
await sess.execute(
|
||||||
|
delete(PostUser).where(PostUser.post_id == post.id)
|
||||||
|
)
|
||||||
|
seen_user_ids: set[int] = set()
|
||||||
|
for idx, a in enumerate(gp.get("authors") or []):
|
||||||
|
uid = ghost_author_to_user.get(a["id"])
|
||||||
|
if uid and uid not in seen_user_ids:
|
||||||
|
seen_user_ids.add(uid)
|
||||||
|
sess.add(PostUser(post_id=post.id, user_id=uid, sort_order=idx))
|
||||||
|
rows_created += 1
|
||||||
|
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
return rows_created
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Content sync (reuse ghost_sync upsert logic)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def run_sync() -> dict:
|
||||||
|
"""Run full Ghost content sync and author→user migration."""
|
||||||
|
from bp.blog.ghost.ghost_sync import (
|
||||||
|
_upsert_author,
|
||||||
|
_upsert_tag,
|
||||||
|
_upsert_post,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("Fetching all content from Ghost...")
|
||||||
|
data = await fetch_all_ghost_content()
|
||||||
|
log.info("Received %d posts/pages from Ghost", len(data))
|
||||||
|
|
||||||
|
# Collect authors and tags
|
||||||
|
author_bucket: dict[str, dict] = {}
|
||||||
|
tag_bucket: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for p in data:
|
||||||
|
for a in p.get("authors") or []:
|
||||||
|
author_bucket[a["id"]] = a
|
||||||
|
if p.get("primary_author"):
|
||||||
|
author_bucket[p["primary_author"]["id"]] = p["primary_author"]
|
||||||
|
for t in p.get("tags") or []:
|
||||||
|
tag_bucket[t["id"]] = t
|
||||||
|
if p.get("primary_tag"):
|
||||||
|
tag_bucket[p["primary_tag"]["id"]] = p["primary_tag"]
|
||||||
|
|
||||||
|
# Step 1: Upsert content into db_blog (existing Ghost sync logic)
|
||||||
|
async with get_session() as sess:
|
||||||
|
async with sess.begin():
|
||||||
|
author_map: dict[str, Author] = {}
|
||||||
|
for ga in author_bucket.values():
|
||||||
|
a = await _upsert_author(sess, ga)
|
||||||
|
author_map[ga["id"]] = a
|
||||||
|
log.info("Upserted %d authors (legacy table)", len(author_map))
|
||||||
|
|
||||||
|
tag_map: dict[str, Tag] = {}
|
||||||
|
for gt in tag_bucket.values():
|
||||||
|
t = await _upsert_tag(sess, gt)
|
||||||
|
tag_map[gt["id"]] = t
|
||||||
|
log.info("Upserted %d tags", len(tag_map))
|
||||||
|
|
||||||
|
for gp in data:
|
||||||
|
await _upsert_post(sess, gp, author_map, tag_map)
|
||||||
|
log.info("Upserted %d posts/pages", len(data))
|
||||||
|
|
||||||
|
# Step 2: Migrate authors → users in db_account
|
||||||
|
log.info("")
|
||||||
|
log.info("--- Migrating Ghost authors → Users ---")
|
||||||
|
ghost_author_to_user = await migrate_authors_to_users(author_bucket)
|
||||||
|
log.info("Mapped %d Ghost authors to User records", len(ghost_author_to_user))
|
||||||
|
|
||||||
|
# Step 3: Populate post_users M2M and set user_id
|
||||||
|
log.info("")
|
||||||
|
log.info("--- Populating post_users M2M ---")
|
||||||
|
pu_rows = await populate_post_users(data, ghost_author_to_user)
|
||||||
|
log.info("Created %d post_users rows", pu_rows)
|
||||||
|
|
||||||
|
n_posts = sum(1 for p in data if not p.get("page"))
|
||||||
|
n_pages = sum(1 for p in data if p.get("page"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"posts": n_posts,
|
||||||
|
"pages": n_pages,
|
||||||
|
"authors": len(author_bucket),
|
||||||
|
"tags": len(tag_bucket),
|
||||||
|
"users_mapped": len(ghost_author_to_user),
|
||||||
|
"post_users_rows": pu_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTML rendering verification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _normalize_html(html: str | None) -> str:
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
return re.sub(r"\s+", " ", html.strip())
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_html_rendering() -> dict:
|
||||||
|
"""Re-render all posts from lexical and compare with stored HTML."""
|
||||||
|
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||||
|
import json
|
||||||
|
|
||||||
|
diffs_found = 0
|
||||||
|
posts_checked = 0
|
||||||
|
posts_no_lexical = 0
|
||||||
|
|
||||||
|
async with get_session() as sess:
|
||||||
|
result = await sess.execute(
|
||||||
|
select(Post).where(
|
||||||
|
Post.deleted_at.is_(None),
|
||||||
|
Post.status == "published",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
posts = result.scalars().all()
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
if not post.lexical:
|
||||||
|
posts_no_lexical += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
posts_checked += 1
|
||||||
|
try:
|
||||||
|
rendered = render_lexical(json.loads(post.lexical))
|
||||||
|
except Exception as e:
|
||||||
|
log.error(
|
||||||
|
"Render failed for post %d (%s): %s",
|
||||||
|
post.id, post.slug, e,
|
||||||
|
)
|
||||||
|
diffs_found += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ghost_html = _normalize_html(post.html)
|
||||||
|
our_html = _normalize_html(rendered)
|
||||||
|
|
||||||
|
if ghost_html != our_html:
|
||||||
|
diffs_found += 1
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
ghost_html.splitlines(keepends=True),
|
||||||
|
our_html.splitlines(keepends=True),
|
||||||
|
fromfile=f"ghost/{post.slug}",
|
||||||
|
tofile=f"rendered/{post.slug}",
|
||||||
|
n=2,
|
||||||
|
)
|
||||||
|
diff_text = "".join(diff)
|
||||||
|
if len(diff_text) > 2000:
|
||||||
|
diff_text = diff_text[:2000] + "\n... (truncated)"
|
||||||
|
log.warning(
|
||||||
|
"HTML diff for post %d (%s):\n%s",
|
||||||
|
post.id, post.slug, diff_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"checked": posts_checked,
|
||||||
|
"no_lexical": posts_no_lexical,
|
||||||
|
"diffs": diffs_found,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verification queries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def run_verification() -> dict:
|
||||||
|
async with get_session() as sess:
|
||||||
|
total_posts = await sess.scalar(
|
||||||
|
select(func.count(Post.id)).where(Post.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
null_html = await sess.scalar(
|
||||||
|
select(func.count(Post.id)).where(
|
||||||
|
Post.deleted_at.is_(None),
|
||||||
|
Post.status == "published",
|
||||||
|
Post.html.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
null_lexical = await sess.scalar(
|
||||||
|
select(func.count(Post.id)).where(
|
||||||
|
Post.deleted_at.is_(None),
|
||||||
|
Post.status == "published",
|
||||||
|
Post.lexical.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Posts with user_id set
|
||||||
|
has_user = await sess.scalar(
|
||||||
|
select(func.count(Post.id)).where(
|
||||||
|
Post.deleted_at.is_(None),
|
||||||
|
Post.user_id.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
no_user = await sess.scalar(
|
||||||
|
select(func.count(Post.id)).where(
|
||||||
|
Post.deleted_at.is_(None),
|
||||||
|
Post.user_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_posts": total_posts,
|
||||||
|
"published_null_html": null_html,
|
||||||
|
"published_null_lexical": null_lexical,
|
||||||
|
"posts_with_user": has_user,
|
||||||
|
"posts_without_user": no_user,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("Final Ghost Sync — Cutover Preparation")
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
# Step 1: Full sync + author→user migration
|
||||||
|
log.info("")
|
||||||
|
log.info("--- Step 1: Full sync from Ghost + author→user migration ---")
|
||||||
|
stats = await run_sync()
|
||||||
|
log.info(
|
||||||
|
"Sync complete: %d posts, %d pages, %d authors, %d tags, %d users mapped",
|
||||||
|
stats["posts"], stats["pages"], stats["authors"], stats["tags"],
|
||||||
|
stats["users_mapped"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Verification queries
|
||||||
|
log.info("")
|
||||||
|
log.info("--- Step 2: Verification queries ---")
|
||||||
|
vq = await run_verification()
|
||||||
|
log.info("Total non-deleted posts/pages: %d", vq["total_posts"])
|
||||||
|
log.info("Published with NULL html: %d", vq["published_null_html"])
|
||||||
|
log.info("Published with NULL lexical: %d", vq["published_null_lexical"])
|
||||||
|
log.info("Posts with user_id: %d", vq["posts_with_user"])
|
||||||
|
log.info("Posts WITHOUT user_id: %d", vq["posts_without_user"])
|
||||||
|
|
||||||
|
if vq["published_null_html"] > 0:
|
||||||
|
log.warning("WARN: Some published posts have no HTML!")
|
||||||
|
if vq["published_null_lexical"] > 0:
|
||||||
|
log.warning("WARN: Some published posts have no Lexical JSON!")
|
||||||
|
if vq["posts_without_user"] > 0:
|
||||||
|
log.warning("WARN: Some posts have no user_id — authors may lack email!")
|
||||||
|
|
||||||
|
# Step 3: HTML rendering verification
|
||||||
|
log.info("")
|
||||||
|
log.info("--- Step 3: HTML rendering verification ---")
|
||||||
|
html_stats = await verify_html_rendering()
|
||||||
|
log.info(
|
||||||
|
"Checked %d posts, %d with diffs, %d without lexical",
|
||||||
|
html_stats["checked"], html_stats["diffs"], html_stats["no_lexical"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if html_stats["diffs"] > 0:
|
||||||
|
log.warning(
|
||||||
|
"WARN: %d posts have HTML rendering differences — "
|
||||||
|
"review diffs above before cutover",
|
||||||
|
html_stats["diffs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
log.info("")
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info("SUMMARY")
|
||||||
|
log.info(" Posts synced: %d", stats["posts"])
|
||||||
|
log.info(" Pages synced: %d", stats["pages"])
|
||||||
|
log.info(" Authors synced: %d", stats["authors"])
|
||||||
|
log.info(" Tags synced: %d", stats["tags"])
|
||||||
|
log.info(" Users mapped: %d", stats["users_mapped"])
|
||||||
|
log.info(" post_users rows: %d", stats["post_users_rows"])
|
||||||
|
log.info(" HTML diffs: %d", html_stats["diffs"])
|
||||||
|
log.info(" Published null HTML: %d", vq["published_null_html"])
|
||||||
|
log.info(" Published null lex: %d", vq["published_null_lexical"])
|
||||||
|
log.info(" Posts without user: %d", vq["posts_without_user"])
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
if (html_stats["diffs"] == 0
|
||||||
|
and vq["published_null_html"] == 0
|
||||||
|
and vq["posts_without_user"] == 0):
|
||||||
|
log.info("All checks passed — safe to proceed with cutover.")
|
||||||
|
else:
|
||||||
|
log.warning("Review warnings above before proceeding.")
|
||||||
|
|
||||||
|
await _engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
89
blog/scripts/migrate_sx_html.py
Normal file
89
blog/scripts/migrate_sx_html.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Re-convert sx_content from lexical JSON to eliminate ~kg-html wrappers and
|
||||||
|
raw caption strings.
|
||||||
|
|
||||||
|
The updated lexical_to_sx converter now produces native sx expressions instead
|
||||||
|
of (1) wrapping HTML/markdown cards in (~kg-html :html "...") and (2) storing
|
||||||
|
captions as escaped HTML strings. This script re-runs the conversion on all
|
||||||
|
posts that already have sx_content, overwriting the old output.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd blog && python3 scripts/migrate_sx_html.py [--dry-run]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(dry_run: bool = False) -> int:
|
||||||
|
from shared.db.session import get_session
|
||||||
|
from models.ghost_content import Post
|
||||||
|
from bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
||||||
|
|
||||||
|
converted = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
async with get_session() as sess:
|
||||||
|
# All posts with lexical content (whether or not sx_content exists)
|
||||||
|
stmt = select(Post).where(
|
||||||
|
and_(
|
||||||
|
Post.lexical.isnot(None),
|
||||||
|
Post.lexical != "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await sess.execute(stmt)
|
||||||
|
posts = result.scalars().all()
|
||||||
|
|
||||||
|
print(f"Found {len(posts)} posts with lexical content")
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
try:
|
||||||
|
new_sx = lexical_to_sx(post.lexical)
|
||||||
|
if post.sx_content == new_sx:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
old_has_kg = "~kg-html" in (post.sx_content or "")
|
||||||
|
old_has_raw = "raw! caption" in (post.sx_content or "")
|
||||||
|
markers = []
|
||||||
|
if old_has_kg:
|
||||||
|
markers.append("~kg-html")
|
||||||
|
if old_has_raw:
|
||||||
|
markers.append("raw-caption")
|
||||||
|
tag = f" [{', '.join(markers)}]" if markers else ""
|
||||||
|
print(f" [DRY RUN] {post.slug}: {len(new_sx)} chars{tag}")
|
||||||
|
else:
|
||||||
|
post.sx_content = new_sx
|
||||||
|
print(f" Converted: {post.slug} ({len(new_sx)} chars)")
|
||||||
|
converted += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if not dry_run and converted:
|
||||||
|
await sess.commit()
|
||||||
|
|
||||||
|
print(f"\nDone: {converted} converted, {skipped} unchanged, {errors} errors")
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Re-convert sx_content to eliminate ~kg-html and raw captions"
|
||||||
|
)
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Preview changes without writing to database")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
asyncio.run(migrate(dry_run=args.dry_run))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,6 +1,69 @@
|
|||||||
"""Blog app service registration."""
|
"""Blog app service registration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.contracts.dtos import PostDTO
|
||||||
|
|
||||||
|
from models.content import Post
|
||||||
|
|
||||||
|
|
||||||
|
def _post_to_dto(post: Post) -> PostDTO:
|
||||||
|
return PostDTO(
|
||||||
|
id=post.id,
|
||||||
|
slug=post.slug,
|
||||||
|
title=post.title,
|
||||||
|
status=post.status,
|
||||||
|
visibility=post.visibility,
|
||||||
|
is_page=post.is_page,
|
||||||
|
feature_image=post.feature_image,
|
||||||
|
html=post.html,
|
||||||
|
sx_content=post.sx_content,
|
||||||
|
excerpt=post.excerpt,
|
||||||
|
custom_excerpt=post.custom_excerpt,
|
||||||
|
published_at=post.published_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlBlogService:
|
||||||
|
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None:
|
||||||
|
post = (
|
||||||
|
await session.execute(select(Post).where(Post.slug == slug))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return _post_to_dto(post) if post else None
|
||||||
|
|
||||||
|
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None:
|
||||||
|
post = (
|
||||||
|
await session.execute(select(Post).where(Post.id == id))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return _post_to_dto(post) if post else None
|
||||||
|
|
||||||
|
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]:
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
result = await session.execute(select(Post).where(Post.id.in_(ids)))
|
||||||
|
return [_post_to_dto(p) for p in result.scalars().all()]
|
||||||
|
|
||||||
|
async def search_posts(
|
||||||
|
self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10,
|
||||||
|
) -> tuple[list[PostDTO], int]:
|
||||||
|
if query:
|
||||||
|
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
|
||||||
|
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
|
||||||
|
else:
|
||||||
|
count_stmt = select(func.count(Post.id))
|
||||||
|
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
|
||||||
|
|
||||||
|
total = (await session.execute(count_stmt)).scalar() or 0
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
result = await session.execute(posts_stmt.limit(per_page).offset(offset))
|
||||||
|
return [_post_to_dto(p) for p in result.scalars().all()], total
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton — import this in blog code.
|
||||||
|
blog_service = SqlBlogService()
|
||||||
|
|
||||||
|
|
||||||
def register_domain_services() -> None:
|
def register_domain_services() -> None:
|
||||||
"""Register services for the blog app.
|
"""Register services for the blog app.
|
||||||
@@ -8,12 +71,8 @@ def register_domain_services() -> None:
|
|||||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
||||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
Cross-app calls go over HTTP via call_action() / fetch_data().
|
||||||
"""
|
"""
|
||||||
from shared.services.registry import services
|
|
||||||
from shared.services.blog_impl import SqlBlogService
|
|
||||||
|
|
||||||
services.blog = SqlBlogService()
|
|
||||||
|
|
||||||
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
# Federation needed for AP shared infrastructure (activitypub blueprint)
|
||||||
|
from shared.services.registry import services
|
||||||
if not services.has("federation"):
|
if not services.has("federation"):
|
||||||
from shared.services.federation_impl import SqlFederationService
|
from shared.services.federation_impl import SqlFederationService
|
||||||
services.federation = SqlFederationService()
|
services.federation = SqlFederationService()
|
||||||
|
|||||||
465
blog/services/post_writer.py
Normal file
465
blog/services/post_writer.py
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
"""Native post/page CRUD — replaces Ghost Admin API writes.
|
||||||
|
|
||||||
|
All operations go directly to db_blog. Ghost is never called.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import nh3
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models.ghost_content import Post, Tag, PostTag, PostUser
|
||||||
|
from shared.browser.app.utils import utcnow
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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("-")
|
||||||
|
|
||||||
|
|
||||||
|
def _reading_time(plaintext: str | None) -> int:
|
||||||
|
"""Estimate reading time in minutes (word count / 265, min 1)."""
|
||||||
|
if not plaintext:
|
||||||
|
return 0
|
||||||
|
words = len(plaintext.split())
|
||||||
|
return max(1, round(words / 265))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plaintext(html: str) -> str:
|
||||||
|
"""Strip HTML tags to get plaintext."""
|
||||||
|
text = re.sub(r"<[^>]+>", "", html)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_html(html: str | None) -> str | None:
|
||||||
|
if not html:
|
||||||
|
return html
|
||||||
|
return nh3.clean(
|
||||||
|
html,
|
||||||
|
tags={
|
||||||
|
"a", "abbr", "acronym", "b", "blockquote", "br", "code",
|
||||||
|
"div", "em", "figcaption", "figure", "h1", "h2", "h3",
|
||||||
|
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p",
|
||||||
|
"pre", "span", "strong", "sub", "sup", "table", "tbody",
|
||||||
|
"td", "th", "thead", "tr", "ul", "video", "source",
|
||||||
|
"picture", "iframe", "audio",
|
||||||
|
},
|
||||||
|
attributes={
|
||||||
|
"*": {"class", "id", "style"},
|
||||||
|
"a": {"href", "title", "target"},
|
||||||
|
"img": {"src", "alt", "title", "width", "height", "loading"},
|
||||||
|
"video": {"src", "controls", "width", "height", "poster"},
|
||||||
|
"audio": {"src", "controls"},
|
||||||
|
"source": {"src", "type"},
|
||||||
|
"iframe": {"src", "width", "height", "frameborder", "allowfullscreen"},
|
||||||
|
"td": {"colspan", "rowspan"},
|
||||||
|
"th": {"colspan", "rowspan"},
|
||||||
|
},
|
||||||
|
link_rel="noopener noreferrer",
|
||||||
|
url_schemes={"http", "https", "mailto"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_and_extract(lexical_json: str) -> tuple[str, str, int]:
|
||||||
|
"""Render HTML from Lexical JSON, extract plaintext, compute reading time.
|
||||||
|
|
||||||
|
Returns (html, plaintext, reading_time).
|
||||||
|
"""
|
||||||
|
from bp.blog.ghost.lexical_renderer import render_lexical
|
||||||
|
|
||||||
|
doc = json.loads(lexical_json) if isinstance(lexical_json, str) else lexical_json
|
||||||
|
html = render_lexical(doc)
|
||||||
|
html = _sanitize_html(html)
|
||||||
|
plaintext = _extract_plaintext(html or "")
|
||||||
|
rt = _reading_time(plaintext)
|
||||||
|
return html, plaintext, rt
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_slug_unique(sess: AsyncSession, slug: str, exclude_post_id: int | None = None) -> str:
|
||||||
|
"""Append -2, -3, etc. if slug already taken."""
|
||||||
|
base_slug = slug
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
q = select(Post.id).where(Post.slug == slug, Post.deleted_at.is_(None))
|
||||||
|
if exclude_post_id:
|
||||||
|
q = q.where(Post.id != exclude_post_id)
|
||||||
|
existing = await sess.scalar(q)
|
||||||
|
if existing is None:
|
||||||
|
return slug
|
||||||
|
counter += 1
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_tags_by_name(sess: AsyncSession, tag_names: list[str]) -> list[Tag]:
|
||||||
|
"""Find or create tags by name. Returns Tag objects in order."""
|
||||||
|
tags: list[Tag] = []
|
||||||
|
for name in tag_names:
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
tag = (await sess.execute(
|
||||||
|
select(Tag).where(Tag.name == name, Tag.deleted_at.is_(None))
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if tag is None:
|
||||||
|
tag = Tag(
|
||||||
|
name=name,
|
||||||
|
slug=_slugify(name),
|
||||||
|
visibility="public",
|
||||||
|
)
|
||||||
|
sess.add(tag)
|
||||||
|
await sess.flush()
|
||||||
|
tags.append(tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
async def _rebuild_post_tags(sess: AsyncSession, post_id: int, tags: list[Tag]) -> None:
|
||||||
|
"""Replace all post_tags for a post."""
|
||||||
|
await sess.execute(delete(PostTag).where(PostTag.post_id == post_id))
|
||||||
|
seen: set[int] = set()
|
||||||
|
for idx, tag in enumerate(tags):
|
||||||
|
if tag.id not in seen:
|
||||||
|
seen.add(tag.id)
|
||||||
|
sess.add(PostTag(post_id=post_id, tag_id=tag.id, sort_order=idx))
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _rebuild_post_users(sess: AsyncSession, post_id: int, user_ids: list[int]) -> None:
|
||||||
|
"""Replace all post_users for a post."""
|
||||||
|
await sess.execute(delete(PostUser).where(PostUser.post_id == post_id))
|
||||||
|
seen: set[int] = set()
|
||||||
|
for idx, uid in enumerate(user_ids):
|
||||||
|
if uid not in seen:
|
||||||
|
seen.add(uid)
|
||||||
|
sess.add(PostUser(post_id=post_id, user_id=uid, sort_order=idx))
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _fire_ap_publish(
|
||||||
|
sess: AsyncSession,
|
||||||
|
post: Post,
|
||||||
|
old_status: str | None,
|
||||||
|
tag_objs: list[Tag],
|
||||||
|
) -> None:
|
||||||
|
"""Fire AP federation activity on status transitions."""
|
||||||
|
if post.is_page or not post.user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
from bp.blog.ghost.ghost_sync import _build_ap_post_data
|
||||||
|
from shared.services.federation_publish import try_publish
|
||||||
|
from shared.infrastructure.urls import app_url
|
||||||
|
|
||||||
|
post_url = app_url("blog", f"/{post.slug}/")
|
||||||
|
|
||||||
|
if post.status == "published":
|
||||||
|
activity_type = "Create" if old_status != "published" else "Update"
|
||||||
|
await try_publish(
|
||||||
|
sess,
|
||||||
|
user_id=post.user_id,
|
||||||
|
activity_type=activity_type,
|
||||||
|
object_type="Note",
|
||||||
|
object_data=_build_ap_post_data(post, post_url, tag_objs),
|
||||||
|
source_type="Post",
|
||||||
|
source_id=post.id,
|
||||||
|
)
|
||||||
|
elif old_status == "published" and post.status != "published":
|
||||||
|
await try_publish(
|
||||||
|
sess,
|
||||||
|
user_id=post.user_id,
|
||||||
|
activity_type="Delete",
|
||||||
|
object_type="Tombstone",
|
||||||
|
object_data={
|
||||||
|
"id": post_url,
|
||||||
|
"formerType": "Note",
|
||||||
|
},
|
||||||
|
source_type="Post",
|
||||||
|
source_id=post.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_post(
|
||||||
|
sess: AsyncSession,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
lexical_json: str,
|
||||||
|
status: str = "draft",
|
||||||
|
user_id: int,
|
||||||
|
feature_image: str | None = None,
|
||||||
|
custom_excerpt: str | None = None,
|
||||||
|
feature_image_caption: str | None = None,
|
||||||
|
tag_names: list[str] | None = None,
|
||||||
|
is_page: bool = False,
|
||||||
|
sx_content: str | None = None,
|
||||||
|
) -> Post:
|
||||||
|
"""Create a new post or page directly in db_blog."""
|
||||||
|
html, plaintext, reading_time = _render_and_extract(lexical_json)
|
||||||
|
slug = await _ensure_slug_unique(sess, _slugify(title or "untitled"))
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
post = Post(
|
||||||
|
title=title or "Untitled",
|
||||||
|
slug=slug,
|
||||||
|
lexical=lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json),
|
||||||
|
sx_content=sx_content,
|
||||||
|
html=html,
|
||||||
|
plaintext=plaintext,
|
||||||
|
reading_time=reading_time,
|
||||||
|
status=status,
|
||||||
|
is_page=is_page,
|
||||||
|
feature_image=feature_image,
|
||||||
|
feature_image_caption=_sanitize_html(feature_image_caption),
|
||||||
|
custom_excerpt=custom_excerpt,
|
||||||
|
user_id=user_id,
|
||||||
|
visibility="public",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
published_at=now if status == "published" else None,
|
||||||
|
)
|
||||||
|
sess.add(post)
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
if tag_names:
|
||||||
|
tags = await _upsert_tags_by_name(sess, tag_names)
|
||||||
|
await _rebuild_post_tags(sess, post.id, tags)
|
||||||
|
if tags:
|
||||||
|
post.primary_tag_id = tags[0].id
|
||||||
|
|
||||||
|
# Post users (author)
|
||||||
|
await _rebuild_post_users(sess, post.id, [user_id])
|
||||||
|
|
||||||
|
# PageConfig for pages
|
||||||
|
if is_page:
|
||||||
|
from shared.models.page_config import PageConfig
|
||||||
|
existing = (await sess.execute(
|
||||||
|
select(PageConfig).where(
|
||||||
|
PageConfig.container_type == "page",
|
||||||
|
PageConfig.container_id == post.id,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is None:
|
||||||
|
sess.add(PageConfig(
|
||||||
|
container_type="page",
|
||||||
|
container_id=post.id,
|
||||||
|
features={},
|
||||||
|
))
|
||||||
|
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
# AP federation
|
||||||
|
if status == "published":
|
||||||
|
tag_objs = (await _upsert_tags_by_name(sess, tag_names)) if tag_names else []
|
||||||
|
await _fire_ap_publish(sess, post, None, tag_objs)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
async def create_page(
|
||||||
|
sess: AsyncSession,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
lexical_json: str,
|
||||||
|
status: str = "draft",
|
||||||
|
user_id: int,
|
||||||
|
feature_image: str | None = None,
|
||||||
|
custom_excerpt: str | None = None,
|
||||||
|
feature_image_caption: str | None = None,
|
||||||
|
tag_names: list[str] | None = None,
|
||||||
|
sx_content: str | None = None,
|
||||||
|
) -> Post:
|
||||||
|
"""Create a new page. Convenience wrapper around create_post."""
|
||||||
|
return await create_post(
|
||||||
|
sess,
|
||||||
|
title=title,
|
||||||
|
lexical_json=lexical_json,
|
||||||
|
status=status,
|
||||||
|
user_id=user_id,
|
||||||
|
feature_image=feature_image,
|
||||||
|
custom_excerpt=custom_excerpt,
|
||||||
|
feature_image_caption=feature_image_caption,
|
||||||
|
tag_names=tag_names,
|
||||||
|
is_page=True,
|
||||||
|
sx_content=sx_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_post(
|
||||||
|
sess: AsyncSession,
|
||||||
|
*,
|
||||||
|
post_id: int,
|
||||||
|
lexical_json: str,
|
||||||
|
title: str | None = None,
|
||||||
|
expected_updated_at: datetime | str,
|
||||||
|
feature_image: str | None = ..., # type: ignore[assignment]
|
||||||
|
custom_excerpt: str | None = ..., # type: ignore[assignment]
|
||||||
|
feature_image_caption: str | None = ..., # type: ignore[assignment]
|
||||||
|
status: str | None = None,
|
||||||
|
sx_content: str | None = ..., # type: ignore[assignment]
|
||||||
|
) -> Post:
|
||||||
|
"""Update post content. Optimistic lock via expected_updated_at.
|
||||||
|
|
||||||
|
Fields set to ... (sentinel) are left unchanged. None clears the field.
|
||||||
|
Raises ValueError on optimistic lock conflict (409).
|
||||||
|
"""
|
||||||
|
_SENTINEL = ...
|
||||||
|
|
||||||
|
post = await sess.get(Post, post_id)
|
||||||
|
if post is None:
|
||||||
|
raise ValueError(f"Post {post_id} not found")
|
||||||
|
|
||||||
|
# Optimistic lock
|
||||||
|
if isinstance(expected_updated_at, str):
|
||||||
|
expected_updated_at = datetime.fromisoformat(
|
||||||
|
expected_updated_at.replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
|
||||||
|
raise OptimisticLockError(
|
||||||
|
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_status = post.status
|
||||||
|
|
||||||
|
# Render content
|
||||||
|
html, plaintext, reading_time = _render_and_extract(lexical_json)
|
||||||
|
post.lexical = lexical_json if isinstance(lexical_json, str) else json.dumps(lexical_json)
|
||||||
|
post.html = html
|
||||||
|
post.plaintext = plaintext
|
||||||
|
post.reading_time = reading_time
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
post.title = title
|
||||||
|
|
||||||
|
if sx_content is not _SENTINEL:
|
||||||
|
post.sx_content = sx_content
|
||||||
|
|
||||||
|
if feature_image is not _SENTINEL:
|
||||||
|
post.feature_image = feature_image
|
||||||
|
if custom_excerpt is not _SENTINEL:
|
||||||
|
post.custom_excerpt = custom_excerpt
|
||||||
|
if feature_image_caption is not _SENTINEL:
|
||||||
|
post.feature_image_caption = _sanitize_html(feature_image_caption)
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
post.status = status
|
||||||
|
if status == "published" and not post.published_at:
|
||||||
|
post.published_at = utcnow()
|
||||||
|
|
||||||
|
post.updated_at = utcnow()
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
# AP federation on status change
|
||||||
|
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||||
|
await _fire_ap_publish(sess, post, old_status, tags)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
_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",
|
||||||
|
"feature_image_alt",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_post_settings(
|
||||||
|
sess: AsyncSession,
|
||||||
|
*,
|
||||||
|
post_id: int,
|
||||||
|
expected_updated_at: datetime | str,
|
||||||
|
tag_names: list[str] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Post:
|
||||||
|
"""Update post settings (slug, tags, SEO, social, etc.).
|
||||||
|
|
||||||
|
Optimistic lock via expected_updated_at.
|
||||||
|
"""
|
||||||
|
post = await sess.get(Post, post_id)
|
||||||
|
if post is None:
|
||||||
|
raise ValueError(f"Post {post_id} not found")
|
||||||
|
|
||||||
|
# Optimistic lock
|
||||||
|
if isinstance(expected_updated_at, str):
|
||||||
|
expected_updated_at = datetime.fromisoformat(
|
||||||
|
expected_updated_at.replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
if post.updated_at and abs((post.updated_at - expected_updated_at).total_seconds()) > 1:
|
||||||
|
raise OptimisticLockError(
|
||||||
|
f"Post was modified at {post.updated_at}, expected {expected_updated_at}"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_status = post.status
|
||||||
|
|
||||||
|
for field in _SETTINGS_FIELDS:
|
||||||
|
val = kwargs.get(field)
|
||||||
|
if val is not None:
|
||||||
|
if field == "slug":
|
||||||
|
val = await _ensure_slug_unique(sess, val, exclude_post_id=post.id)
|
||||||
|
if field == "featured":
|
||||||
|
val = bool(val)
|
||||||
|
if field == "email_only":
|
||||||
|
val = bool(val)
|
||||||
|
if field == "published_at":
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||||
|
setattr(post, field, val)
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
if tag_names is not None:
|
||||||
|
tags = await _upsert_tags_by_name(sess, tag_names)
|
||||||
|
await _rebuild_post_tags(sess, post.id, tags)
|
||||||
|
post.primary_tag_id = tags[0].id if tags else None
|
||||||
|
|
||||||
|
post.updated_at = utcnow()
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
# AP federation if visibility/status changed
|
||||||
|
tags_obj = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||||
|
await _fire_ap_publish(sess, post, old_status, tags_obj)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_post(sess: AsyncSession, post_id: int) -> None:
|
||||||
|
"""Soft-delete a post via deleted_at."""
|
||||||
|
post = await sess.get(Post, post_id)
|
||||||
|
if post is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
old_status = post.status
|
||||||
|
post.deleted_at = utcnow()
|
||||||
|
post.status = "deleted"
|
||||||
|
await sess.flush()
|
||||||
|
|
||||||
|
# Fire AP Delete if was published
|
||||||
|
if old_status == "published" and post.user_id:
|
||||||
|
tags = list(post.tags) if hasattr(post, "tags") and post.tags else []
|
||||||
|
await _fire_ap_publish(sess, post, old_status, tags)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OptimisticLockError(Exception):
|
||||||
|
"""Raised when optimistic lock check fails (stale updated_at)."""
|
||||||
|
pass
|
||||||
@@ -14,12 +14,6 @@
|
|||||||
(h1 :class "text-3xl font-bold" "Snippets"))
|
(h1 :class "text-3xl font-bold" "Snippets"))
|
||||||
(div :id "snippets-list" list)))
|
(div :id "snippets-list" list)))
|
||||||
|
|
||||||
(defcomp ~blog-snippets-empty ()
|
|
||||||
(div :class "bg-white rounded-lg shadow"
|
|
||||||
(div :class "p-8 text-center text-stone-400"
|
|
||||||
(i :class "fa fa-puzzle-piece text-4xl mb-2")
|
|
||||||
(p "No snippets yet. Create one from the blog editor."))))
|
|
||||||
|
|
||||||
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
|
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
|
||||||
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
|
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
|
||||||
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
|
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
|
||||||
@@ -28,16 +22,6 @@
|
|||||||
(defcomp ~blog-snippet-option (&key value selected label)
|
(defcomp ~blog-snippet-option (&key value selected label)
|
||||||
(option :value value :selected selected label))
|
(option :value value :selected selected label))
|
||||||
|
|
||||||
(defcomp ~blog-snippet-delete-button (&key confirm-text delete-url hx-headers)
|
|
||||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"
|
|
||||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
|
||||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
|
||||||
:data-confirm-event "confirmed"
|
|
||||||
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#snippets-list" :sx-swap "innerHTML"
|
|
||||||
:sx-headers hx-headers
|
|
||||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
|
|
||||||
(i :class "fa fa-trash") " Delete"))
|
|
||||||
|
|
||||||
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra)
|
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra)
|
||||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||||
(div :class "flex-1 min-w-0"
|
(div :class "flex-1 min-w-0"
|
||||||
@@ -58,16 +42,6 @@
|
|||||||
(div :id "menu-item-form" :class "mb-6")
|
(div :id "menu-item-form" :class "mb-6")
|
||||||
(div :id "menu-items-list" list)))
|
(div :id "menu-items-list" list)))
|
||||||
|
|
||||||
(defcomp ~blog-menu-items-empty ()
|
|
||||||
(div :class "bg-white rounded-lg shadow"
|
|
||||||
(div :class "p-8 text-center text-stone-400"
|
|
||||||
(i :class "fa fa-inbox text-4xl mb-2")
|
|
||||||
(p "No menu items yet. Add one to get started!"))))
|
|
||||||
|
|
||||||
(defcomp ~blog-menu-item-image (&key src label)
|
|
||||||
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
|
|
||||||
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
|
|
||||||
|
|
||||||
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text hx-headers)
|
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text hx-headers)
|
||||||
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
|
||||||
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
|
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
|
||||||
@@ -80,14 +54,9 @@
|
|||||||
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
|
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
|
||||||
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
|
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
|
||||||
(i :class "fa fa-edit") " Edit")
|
(i :class "fa fa-edit") " Edit")
|
||||||
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
|
(~delete-btn :url delete-url :trigger-target "#menu-items-list"
|
||||||
:data-confirm-text confirm-text :data-confirm-icon "warning"
|
:title "Delete menu item?" :text confirm-text
|
||||||
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
|
:sx-headers hx-headers))))
|
||||||
:data-confirm-event "confirmed"
|
|
||||||
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#menu-items-list" :sx-swap "innerHTML"
|
|
||||||
:sx-headers hx-headers
|
|
||||||
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
|
|
||||||
(i :class "fa fa-trash") " Delete"))))
|
|
||||||
|
|
||||||
(defcomp ~blog-menu-items-list (&key rows)
|
(defcomp ~blog-menu-items-list (&key rows)
|
||||||
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
|
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
|
||||||
@@ -123,9 +92,6 @@
|
|||||||
(defcomp ~blog-tag-groups-list (&key items)
|
(defcomp ~blog-tag-groups-list (&key items)
|
||||||
(ul :class "space-y-2" items))
|
(ul :class "space-y-2" items))
|
||||||
|
|
||||||
(defcomp ~blog-tag-groups-empty ()
|
|
||||||
(p :class "text-stone-500 text-sm" "No tag groups yet."))
|
|
||||||
|
|
||||||
(defcomp ~blog-unassigned-tag (&key name)
|
(defcomp ~blog-unassigned-tag (&key name)
|
||||||
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
|
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
|
||||||
|
|
||||||
@@ -176,3 +142,30 @@
|
|||||||
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
|
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
|
||||||
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
|
||||||
edit-form delete-form))
|
edit-form delete-form))
|
||||||
|
|
||||||
|
;; Preview panel components
|
||||||
|
|
||||||
|
(defcomp ~blog-preview-panel (&key sections)
|
||||||
|
(div :class "max-w-4xl mx-auto px-4 py-6 space-y-4"
|
||||||
|
(style "
|
||||||
|
.sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
|
||||||
|
.sx-list, .json-obj, .json-arr { display: block; }
|
||||||
|
.sx-paren { color: #64748b; }
|
||||||
|
.sx-sym { color: #0369a1; }
|
||||||
|
.sx-kw { color: #7c3aed; }
|
||||||
|
.sx-str { color: #15803d; }
|
||||||
|
.sx-num { color: #c2410c; }
|
||||||
|
.sx-bool { color: #b91c1c; font-weight: 600; }
|
||||||
|
.json-brace, .json-bracket { color: #64748b; }
|
||||||
|
.json-key { color: #7c3aed; }
|
||||||
|
.json-str { color: #15803d; }
|
||||||
|
.json-num { color: #c2410c; }
|
||||||
|
.json-lit { color: #b91c1c; font-weight: 600; }
|
||||||
|
.json-field { display: block; }
|
||||||
|
")
|
||||||
|
sections))
|
||||||
|
|
||||||
|
(defcomp ~blog-preview-section (&key title content)
|
||||||
|
(details :class "border rounded bg-white"
|
||||||
|
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
|
||||||
|
(div :class "p-4 overflow-x-auto text-xs" content)))
|
||||||
|
|||||||
@@ -25,13 +25,15 @@
|
|||||||
excerpt
|
excerpt
|
||||||
(div :class "hidden md:block" at-bar)))
|
(div :class "hidden md:block" at-bar)))
|
||||||
|
|
||||||
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content)
|
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content sx-content)
|
||||||
(<> (article :class "relative"
|
(<> (article :class "relative"
|
||||||
draft
|
draft
|
||||||
chrome
|
chrome
|
||||||
(when feature-image (div :class "mb-3 flex justify-center"
|
(when feature-image (div :class "mb-3 flex justify-center"
|
||||||
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
|
(img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))
|
||||||
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content))))
|
(if sx-content
|
||||||
|
(div :class "blog-content p-2" sx-content)
|
||||||
|
(when html-content (div :class "blog-content p-2" (~rich-text :html html-content)))))
|
||||||
(div :class "pb-8")))
|
(div :class "pb-8")))
|
||||||
|
|
||||||
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
(defcomp ~blog-meta (&key robots page-title desc canonical og-type og-title image twitter-card twitter-title)
|
||||||
@@ -50,11 +52,8 @@
|
|||||||
(meta :name "twitter:description" :content desc)
|
(meta :name "twitter:description" :content desc)
|
||||||
(when image (meta :name "twitter:image" :content image))))
|
(when image (meta :name "twitter:image" :content image))))
|
||||||
|
|
||||||
(defcomp ~blog-home-main (&key html-content)
|
(defcomp ~blog-home-main (&key html-content sx-content)
|
||||||
(article :class "relative" (div :class "blog-content p-2" (~rich-text :html html-content))))
|
(article :class "relative"
|
||||||
|
(if sx-content
|
||||||
(defcomp ~blog-admin-empty ()
|
(div :class "blog-content p-2" sx-content)
|
||||||
(div :class "pb-8"))
|
(div :class "blog-content p-2" (~rich-text :html html-content)))))
|
||||||
|
|
||||||
(defcomp ~blog-settings-empty ()
|
|
||||||
(div :class "max-w-2xl mx-auto px-4 py-6"))
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||||
|
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value "")
|
||||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :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 "")
|
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")
|
||||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||||
@@ -34,11 +35,22 @@
|
|||||||
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
||||||
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an 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")
|
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed")
|
||||||
(div :id "lexical-editor" :class "relative w-full bg-transparent")
|
;; Editor tabs: SX (primary) and Koenig (legacy)
|
||||||
|
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
|
||||||
|
(button :type "button" :id "editor-tab-sx"
|
||||||
|
:class "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent"
|
||||||
|
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||||
|
"SX Editor")
|
||||||
|
(button :type "button" :id "editor-tab-koenig"
|
||||||
|
:class "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"
|
||||||
|
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||||
|
"Koenig (Legacy)"))
|
||||||
|
(div :id "sx-editor" :class "relative w-full bg-transparent")
|
||||||
|
(div :id "lexical-editor" :class "relative w-full bg-transparent" :style "display:none")
|
||||||
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
(div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||||
(select :name "status"
|
(select :name "status"
|
||||||
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
:class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||||
(option :value "draft" :selected t "Draft")
|
(option :value "draft" :selected true "Draft")
|
||||||
(option :value "published" "Published"))
|
(option :value "published" "Published"))
|
||||||
(button :type "submit"
|
(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-label))))
|
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
|
||||||
@@ -50,5 +62,146 @@
|
|||||||
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
|
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
|
||||||
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
|
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
|
||||||
|
|
||||||
(defcomp ~blog-editor-scripts (&key js-src init-js)
|
(defcomp ~blog-editor-scripts (&key js-src sx-editor-js-src init-js)
|
||||||
(<> (script :src js-src) (script init-js)))
|
(<> (script :src js-src)
|
||||||
|
(when sx-editor-js-src (script :src sx-editor-js-src))
|
||||||
|
(script init-js)))
|
||||||
|
|
||||||
|
;; SX editor styles — comprehensive CSS for the Koenig-style block editor
|
||||||
|
(defcomp ~sx-editor-styles ()
|
||||||
|
(style
|
||||||
|
;; Editor container
|
||||||
|
".sx-editor { position: relative; font-size: 18px; line-height: 1.6; font-family: Georgia, 'Times New Roman', serif; color: #1c1917; }"
|
||||||
|
".sx-blocks-container { display: flex; flex-direction: column; min-height: 300px; cursor: text; padding-left: 48px; }"
|
||||||
|
|
||||||
|
;; Block base
|
||||||
|
".sx-block { position: relative; margin: 1px 0; }"
|
||||||
|
".sx-block-content { padding: 2px 0; }"
|
||||||
|
".sx-editable { outline: none; min-height: 1.6em; }"
|
||||||
|
".sx-editable:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
|
||||||
|
|
||||||
|
;; Text block styles
|
||||||
|
".sx-heading { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 700; }"
|
||||||
|
".sx-block[data-sx-tag='h2'] .sx-heading { font-size: 2em; line-height: 1.25; margin: 0.5em 0 0.25em; }"
|
||||||
|
".sx-block[data-sx-tag='h3'] .sx-heading { font-size: 1.5em; line-height: 1.3; margin: 0.4em 0 0.2em; }"
|
||||||
|
".sx-quote { border-left: 3px solid #d6d3d1; padding-left: 16px; font-style: italic; color: #57534e; }"
|
||||||
|
|
||||||
|
;; HR
|
||||||
|
".sx-block-hr { padding: 12px 0; }"
|
||||||
|
".sx-hr { border: none; border-top: 1px solid #e7e5e4; }"
|
||||||
|
|
||||||
|
;; Code blocks
|
||||||
|
".sx-block-code { background: #1c1917; border-radius: 6px; padding: 16px; margin: 8px 0; }"
|
||||||
|
".sx-code-header { margin-bottom: 8px; }"
|
||||||
|
".sx-code-lang { background: transparent; border: none; color: #a8a29e; font-size: 12px; outline: none; width: 120px; font-family: monospace; }"
|
||||||
|
".sx-code-textarea { width: 100%; background: transparent; border: none; color: #fafaf9; font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace; font-size: 14px; line-height: 1.5; resize: none; outline: none; min-height: 60px; tab-size: 2; }"
|
||||||
|
|
||||||
|
;; List blocks
|
||||||
|
".sx-list-content { padding-left: 0; }"
|
||||||
|
".sx-list-item { padding: 2px 0 2px 24px; position: relative; outline: none; min-height: 1.6em; }"
|
||||||
|
".sx-list-item:empty:before { content: attr(data-placeholder); color: #d6d3d1; pointer-events: none; }"
|
||||||
|
".sx-list-item:before { content: '\\2022'; position: absolute; left: 6px; color: #78716c; }"
|
||||||
|
".sx-block[data-sx-tag='ol'] { counter-reset: sx-list; }"
|
||||||
|
".sx-block[data-sx-tag='ol'] .sx-list-item { counter-increment: sx-list; }"
|
||||||
|
".sx-block[data-sx-tag='ol'] .sx-list-item:before { content: counter(sx-list) '.'; font-size: 14px; }"
|
||||||
|
|
||||||
|
;; Card blocks
|
||||||
|
".sx-block-card { margin: 12px 0; border-radius: 4px; position: relative; transition: box-shadow 0.15s; }"
|
||||||
|
".sx-block-card:hover { box-shadow: 0 0 0 1px #d6d3d1; }"
|
||||||
|
".sx-block-card.sx-card-editing { box-shadow: 0 0 0 2px #3b82f6; }"
|
||||||
|
".sx-card-preview { cursor: pointer; }"
|
||||||
|
".sx-card-preview img { max-width: 100%; }"
|
||||||
|
".sx-card-fallback { padding: 24px; text-align: center; color: #a8a29e; font-style: italic; background: #fafaf9; border-radius: 4px; }"
|
||||||
|
".sx-card-caption { padding: 8px 0; font-size: 14px; color: #78716c; text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-card-caption:empty:before { content: attr(data-placeholder); color: #d6d3d1; }"
|
||||||
|
|
||||||
|
;; Card toolbar
|
||||||
|
".sx-card-toolbar { position: absolute; top: -36px; right: 0; z-index: 10; display: none; gap: 4px; }"
|
||||||
|
".sx-block-card:hover .sx-card-toolbar { display: flex; }"
|
||||||
|
".sx-card-editing .sx-card-toolbar { display: flex; }"
|
||||||
|
".sx-card-tool-btn { width: 28px; height: 28px; border-radius: 4px; border: 1px solid #d6d3d1; background: white; color: #78716c; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; transition: all 0.1s; }"
|
||||||
|
".sx-card-tool-btn:hover { background: #fafaf9; color: #dc2626; border-color: #dc2626; }"
|
||||||
|
|
||||||
|
;; Card edit panel
|
||||||
|
".sx-card-edit { padding: 16px; background: #fafaf9; border-radius: 4px; }"
|
||||||
|
|
||||||
|
;; Plus button (Koenig-style floating)
|
||||||
|
".sx-plus-container { position: absolute; z-index: 40; display: flex; align-items: center; }"
|
||||||
|
".sx-plus-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #d6d3d1; background: white; color: #a8a29e; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; padding: 0; }"
|
||||||
|
".sx-plus-btn:hover { border-color: #1c1917; color: #1c1917; }"
|
||||||
|
".sx-plus-btn-open { transform: rotate(45deg); border-color: #1c1917; color: #1c1917; }"
|
||||||
|
".sx-plus-btn svg { width: 14px; height: 14px; }"
|
||||||
|
|
||||||
|
;; Plus menu
|
||||||
|
".sx-plus-menu { position: absolute; top: 36px; left: -8px; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 8px; width: 320px; max-height: 420px; overflow-y: auto; }"
|
||||||
|
".sx-plus-menu-section { margin-bottom: 4px; }"
|
||||||
|
".sx-plus-menu-heading { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #a8a29e; padding: 6px 8px 2px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-plus-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 8px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-plus-menu-item:hover { background: #f5f5f4; }"
|
||||||
|
".sx-plus-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
|
||||||
|
".sx-plus-menu-label { font-weight: 500; white-space: nowrap; }"
|
||||||
|
".sx-plus-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
|
||||||
|
|
||||||
|
;; Slash command menu
|
||||||
|
".sx-slash-menu { position: absolute; z-index: 50; background: white; border: 1px solid #e7e5e4; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); padding: 4px; width: 320px; max-height: 300px; overflow-y: auto; }"
|
||||||
|
".sx-slash-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: none; background: none; cursor: pointer; font-size: 14px; color: #1c1917; border-radius: 4px; text-align: left; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-slash-menu-item:hover { background: #f5f5f4; }"
|
||||||
|
".sx-slash-menu-icon { width: 24px; text-align: center; font-size: 14px; color: #78716c; flex-shrink: 0; }"
|
||||||
|
".sx-slash-menu-label { font-weight: 500; white-space: nowrap; }"
|
||||||
|
".sx-slash-menu-desc { color: #a8a29e; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }"
|
||||||
|
|
||||||
|
;; Format toolbar
|
||||||
|
".sx-format-bar { position: absolute; z-index: 100; display: flex; gap: 1px; background: #1c1917; border-radius: 6px; padding: 2px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-format-btn { border: none; background: none; color: #e7e5e4; cursor: pointer; padding: 6px 10px; border-radius: 4px; font-size: 14px; line-height: 1; }"
|
||||||
|
".sx-format-btn:hover { background: #44403c; color: white; }"
|
||||||
|
".sx-fmt-bold { font-weight: 700; }"
|
||||||
|
".sx-fmt-italic { font-style: italic; }"
|
||||||
|
|
||||||
|
;; Card edit UI elements
|
||||||
|
".sx-edit-controls { display: flex; flex-direction: column; gap: 8px; }"
|
||||||
|
".sx-edit-row { display: flex; align-items: center; gap: 8px; }"
|
||||||
|
".sx-edit-label { font-size: 12px; font-weight: 600; color: #78716c; min-width: 80px; text-transform: uppercase; letter-spacing: 0.03em; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-edit-input { flex: 1; padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-edit-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }"
|
||||||
|
".sx-edit-select { padding: 6px 10px; border: 1px solid #d6d3d1; border-radius: 4px; font-size: 14px; outline: none; background: white; }"
|
||||||
|
".sx-edit-btn { padding: 8px 16px; border: 1px solid #d6d3d1; border-radius: 6px; font-size: 14px; cursor: pointer; background: white; color: #1c1917; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; transition: all 0.1s; }"
|
||||||
|
".sx-edit-btn:hover { background: #f5f5f4; border-color: #a8a29e; }"
|
||||||
|
".sx-edit-btn-sm { padding: 4px 12px; font-size: 12px; }"
|
||||||
|
".sx-edit-info { font-size: 12px; color: #a8a29e; }"
|
||||||
|
".sx-edit-status { font-size: 13px; color: #78716c; margin-top: 4px; }"
|
||||||
|
".sx-edit-url-input { font-family: monospace; }"
|
||||||
|
".sx-edit-url-display { font-size: 12px; color: #78716c; font-family: monospace; margin: 4px 0; word-break: break-all; }"
|
||||||
|
".sx-edit-checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 14px; color: #44403c; cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-edit-img-preview { max-width: 100%; max-height: 300px; object-fit: contain; border-radius: 4px; margin-bottom: 12px; }"
|
||||||
|
".sx-edit-embed-preview { margin-bottom: 8px; }"
|
||||||
|
".sx-edit-embed-preview iframe { max-width: 100%; }"
|
||||||
|
".sx-edit-audio-player { width: 100%; margin-bottom: 12px; }"
|
||||||
|
".sx-edit-video-player { width: 100%; max-height: 300px; margin-bottom: 12px; border-radius: 4px; }"
|
||||||
|
".sx-edit-html-textarea { width: 100%; min-height: 120px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; padding: 12px; border: 1px solid #d6d3d1; border-radius: 4px; resize: vertical; outline: none; background: #fafaf9; line-height: 1.5; tab-size: 2; }"
|
||||||
|
".sx-edit-html-textarea:focus { border-color: #3b82f6; }"
|
||||||
|
".sx-edit-callout-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; }"
|
||||||
|
".sx-edit-toggle-content { padding: 8px 12px; background: white; border: 1px solid #d6d3d1; border-radius: 4px; min-height: 40px; margin-top: 4px; }"
|
||||||
|
".sx-edit-emoji-input { width: 60px; text-align: center; font-size: 20px; padding: 4px; margin-bottom: 8px; }"
|
||||||
|
|
||||||
|
;; Color picker
|
||||||
|
".sx-edit-color-row { display: flex; gap: 6px; margin-bottom: 8px; }"
|
||||||
|
".sx-edit-color-swatch { width: 28px; height: 28px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: all 0.1s; }"
|
||||||
|
".sx-edit-color-swatch:hover { transform: scale(1.15); }"
|
||||||
|
".sx-edit-color-swatch.active { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }"
|
||||||
|
|
||||||
|
;; Gallery grid
|
||||||
|
".sx-edit-gallery-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }"
|
||||||
|
".sx-edit-gallery-thumb { position: relative; aspect-ratio: 1; overflow: hidden; border-radius: 4px; }"
|
||||||
|
".sx-edit-gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }"
|
||||||
|
".sx-edit-gallery-remove { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border-radius: 50%; background: rgba(0,0,0,0.6); color: white; border: none; cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.1s; }"
|
||||||
|
".sx-edit-gallery-thumb:hover .sx-edit-gallery-remove { opacity: 1; }"
|
||||||
|
|
||||||
|
;; Upload area
|
||||||
|
".sx-upload-area { padding: 40px 24px; border: 2px dashed #d6d3d1; border-radius: 8px; text-align: center; cursor: pointer; transition: all 0.15s; }"
|
||||||
|
".sx-upload-area:hover, .sx-upload-dragover { border-color: #3b82f6; background: rgba(59,130,246,0.03); }"
|
||||||
|
".sx-upload-icon { font-size: 32px; color: #a8a29e; margin-bottom: 8px; }"
|
||||||
|
".sx-upload-msg { font-size: 14px; color: #78716c; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }"
|
||||||
|
".sx-upload-progress { font-size: 13px; color: #3b82f6; margin-top: 8px; }"
|
||||||
|
|
||||||
|
;; Drag over editor
|
||||||
|
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
|
||||||
|
|||||||
31
blog/sx/handlers/link-card.sx
Normal file
31
blog/sx/handlers/link-card.sx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
;; Blog link-card fragment handler
|
||||||
|
;;
|
||||||
|
;; Renders link-card(s) for blog posts by slug.
|
||||||
|
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).
|
||||||
|
|
||||||
|
(defhandler link-card (&key slug keys)
|
||||||
|
(if keys
|
||||||
|
(let ((slugs (split keys ",")))
|
||||||
|
(<> (map (fn (s)
|
||||||
|
(let ((post (query "blog" "post-by-slug" :slug (trim s))))
|
||||||
|
(when post
|
||||||
|
(<> (str "<!-- fragment:" (trim s) " -->")
|
||||||
|
(~link-card
|
||||||
|
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||||
|
:title (get post "title")
|
||||||
|
:image (get post "feature_image")
|
||||||
|
:icon "fas fa-file-alt"
|
||||||
|
:subtitle (or (get post "custom_excerpt") (get post "excerpt"))
|
||||||
|
:detail (get post "published_at_display")
|
||||||
|
:data-app "blog"))))) slugs)))
|
||||||
|
(when slug
|
||||||
|
(let ((post (query "blog" "post-by-slug" :slug slug)))
|
||||||
|
(when post
|
||||||
|
(~link-card
|
||||||
|
:link (app-url "blog" (str "/" (get post "slug") "/"))
|
||||||
|
:title (get post "title")
|
||||||
|
:image (get post "feature_image")
|
||||||
|
:icon "fas fa-file-alt"
|
||||||
|
:subtitle (or (get post "custom_excerpt") (get post "excerpt"))
|
||||||
|
:detail (get post "published_at_display")
|
||||||
|
:data-app "blog"))))))
|
||||||
80
blog/sx/handlers/nav-tree.sx
Normal file
80
blog/sx/handlers/nav-tree.sx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
;; Blog nav-tree fragment handler
|
||||||
|
;;
|
||||||
|
;; Renders the full scrollable navigation menu bar with app icons.
|
||||||
|
;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB.
|
||||||
|
|
||||||
|
(defhandler nav-tree (&key app_name path)
|
||||||
|
(let ((app (or app_name ""))
|
||||||
|
(cur-path (or path "/"))
|
||||||
|
(first-seg (first (filter (fn (s) (not (empty? s)))
|
||||||
|
(split (trim cur-path) "/"))))
|
||||||
|
(items (nav-tree))
|
||||||
|
(nav-cls "whitespace-nowrap flex items-center gap-2 rounded p-2 text-sm")
|
||||||
|
|
||||||
|
;; App slug → URL mapping
|
||||||
|
(app-slugs (dict
|
||||||
|
:cart (app-url "cart" "/")
|
||||||
|
:market (app-url "market" "/")
|
||||||
|
:events (app-url "events" "/")
|
||||||
|
:federation (app-url "federation" "/")
|
||||||
|
:account (app-url "account" "/")
|
||||||
|
:artdag (app-url "artdag" "/"))))
|
||||||
|
|
||||||
|
(let ((item-sxs
|
||||||
|
(<>
|
||||||
|
;; Nav items from DB
|
||||||
|
(map (fn (item)
|
||||||
|
(let ((item-slug (or (get item "slug") ""))
|
||||||
|
(href (or (get app-slugs item-slug)
|
||||||
|
(app-url "blog" (str "/" item-slug "/"))))
|
||||||
|
(selected (or (= item-slug (or first-seg ""))
|
||||||
|
(= item-slug app))))
|
||||||
|
(~blog-nav-item-link
|
||||||
|
:href href
|
||||||
|
:hx-get href
|
||||||
|
:selected (if selected "true" "false")
|
||||||
|
:nav-cls nav-cls
|
||||||
|
:img (~img-or-placeholder
|
||||||
|
:src (get item "feature_image")
|
||||||
|
:alt (or (get item "label") item-slug)
|
||||||
|
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||||
|
:label (or (get item "label") item-slug)))) items)
|
||||||
|
|
||||||
|
;; Hardcoded artdag link
|
||||||
|
(~blog-nav-item-link
|
||||||
|
:href (app-url "artdag" "/")
|
||||||
|
:hx-get (app-url "artdag" "/")
|
||||||
|
:selected (if (or (= "artdag" (or first-seg ""))
|
||||||
|
(= "artdag" app)) "true" "false")
|
||||||
|
:nav-cls nav-cls
|
||||||
|
:img (~img-or-placeholder
|
||||||
|
:src nil :alt "art-dag"
|
||||||
|
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
||||||
|
:label "art-dag")))
|
||||||
|
|
||||||
|
;; Scroll wrapper IDs + hyperscript
|
||||||
|
(arrow-cls "scrolling-menu-arrow-menu-items-container")
|
||||||
|
(cid "menu-items-container")
|
||||||
|
(left-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft - 200"))
|
||||||
|
(scroll-hs (str "on scroll "
|
||||||
|
"set cls to '" arrow-cls "' "
|
||||||
|
"set arrows to document.getElementsByClassName(cls) "
|
||||||
|
"set show to (window.innerWidth >= 640 and "
|
||||||
|
"my.scrollWidth > my.clientWidth) "
|
||||||
|
"repeat for arrow in arrows "
|
||||||
|
"if show remove .hidden from arrow add .flex to arrow "
|
||||||
|
"else add .hidden to arrow remove .flex from arrow end "
|
||||||
|
"end"))
|
||||||
|
(right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200")))
|
||||||
|
|
||||||
|
(if (empty? items)
|
||||||
|
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
|
||||||
|
(~scroll-nav-wrapper
|
||||||
|
:wrapper-id "menu-items-nav-wrapper"
|
||||||
|
:container-id cid
|
||||||
|
:arrow-cls arrow-cls
|
||||||
|
:left-hs left-hs
|
||||||
|
:scroll-hs scroll-hs
|
||||||
|
:right-hs right-hs
|
||||||
|
:items item-sxs
|
||||||
|
:oob true)))))
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
;; Blog header components
|
;; Blog header components
|
||||||
|
|
||||||
(defcomp ~blog-header-label ()
|
|
||||||
(div))
|
|
||||||
|
|
||||||
(defcomp ~blog-container-nav (&key container-nav)
|
(defcomp ~blog-container-nav (&key container-nav)
|
||||||
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
(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" container-nav))
|
:id "entries-calendars-nav-wrapper" container-nav))
|
||||||
|
|||||||
@@ -1,55 +1,8 @@
|
|||||||
;; Blog index components
|
;; Blog index components
|
||||||
|
|
||||||
(defcomp ~blog-end-of-results ()
|
|
||||||
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
|
|
||||||
|
|
||||||
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
|
|
||||||
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
|
||||||
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
|
|
||||||
:sx-swap "outerHTML" :_ hyperscript
|
|
||||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
|
||||||
(div :class "js-loading hidden flex justify-center py-8"
|
|
||||||
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
|
|
||||||
(div :class "js-neterr hidden text-center py-8 text-stone-400"
|
|
||||||
(i :class "fa fa-exclamation-triangle text-2xl")
|
|
||||||
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
|
|
||||||
|
|
||||||
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
|
|
||||||
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
|
|
||||||
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
|
|
||||||
:sx-swap "outerHTML" :_ hyperscript
|
|
||||||
:role "status" :aria-live "polite" :aria-hidden "true"
|
|
||||||
(div :class "js-loading hidden flex justify-center py-2"
|
|
||||||
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
|
|
||||||
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
|
|
||||||
|
|
||||||
(defcomp ~blog-page-sentinel (&key id next-url)
|
|
||||||
(div :id id :class "h-4 opacity-0 pointer-events-none"
|
|
||||||
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"))
|
|
||||||
|
|
||||||
(defcomp ~blog-no-pages ()
|
(defcomp ~blog-no-pages ()
|
||||||
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
|
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
|
||||||
|
|
||||||
(defcomp ~blog-list-svg ()
|
|
||||||
(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")))
|
|
||||||
|
|
||||||
(defcomp ~blog-tile-svg ()
|
|
||||||
(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")))
|
|
||||||
|
|
||||||
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg tile-svg)
|
|
||||||
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
|
|
||||||
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
|
|
||||||
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
|
|
||||||
:_ "on click js localStorage.removeItem('blog_view') end" list-svg)
|
|
||||||
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
|
|
||||||
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
|
|
||||||
:_ "on click js localStorage.setItem('blog_view','tile') end" tile-svg)))
|
|
||||||
|
|
||||||
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
|
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
|
||||||
(div :class "flex justify-center gap-1 px-3 pt-3"
|
(div :class "flex justify-center gap-1 px-3 pt-3"
|
||||||
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
|
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
|
||||||
|
|||||||
153
blog/sx/kg_cards.sx
Normal file
153
blog/sx/kg_cards.sx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
;; KG card components — Ghost/Koenig-compatible card rendering
|
||||||
|
;; Produces same HTML structure as lexical_renderer.py so cards.css works unchanged.
|
||||||
|
;; Used by both display pipeline and block editor.
|
||||||
|
|
||||||
|
;; @css kg-card kg-image-card kg-width-wide kg-width-full kg-gallery-card kg-gallery-container kg-gallery-row kg-gallery-image kg-embed-card kg-bookmark-card kg-bookmark-container kg-bookmark-content kg-bookmark-title kg-bookmark-description kg-bookmark-metadata kg-bookmark-icon kg-bookmark-author kg-bookmark-publisher kg-bookmark-thumbnail kg-callout-card kg-callout-emoji kg-callout-text kg-button-card kg-btn kg-btn-accent kg-toggle-card kg-toggle-heading kg-toggle-heading-text kg-toggle-card-icon kg-toggle-content kg-audio-card kg-audio-thumbnail kg-audio-player-container kg-audio-title kg-audio-player kg-audio-play-icon kg-audio-current-time kg-audio-time kg-audio-seek-slider kg-audio-playback-rate kg-audio-unmute-icon kg-audio-volume-slider kg-video-card kg-video-container kg-file-card kg-file-card-container kg-file-card-contents kg-file-card-title kg-file-card-filesize kg-file-card-icon kg-file-card-caption kg-align-center kg-align-left kg-callout-card-grey kg-callout-card-white kg-callout-card-blue kg-callout-card-green kg-callout-card-yellow kg-callout-card-red kg-callout-card-pink kg-callout-card-purple kg-callout-card-accent kg-html-card kg-md-card placeholder
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Image card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-image (&key src alt caption width href)
|
||||||
|
(figure :class (str "kg-card kg-image-card"
|
||||||
|
(if (= width "wide") " kg-width-wide"
|
||||||
|
(if (= width "full") " kg-width-full" "")))
|
||||||
|
(if href
|
||||||
|
(a :href href (img :src src :alt (or alt "") :loading "lazy"))
|
||||||
|
(img :src src :alt (or alt "") :loading "lazy"))
|
||||||
|
(when caption (figcaption caption))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Gallery card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-gallery (&key images caption)
|
||||||
|
(figure :class "kg-card kg-gallery-card kg-width-wide"
|
||||||
|
(div :class "kg-gallery-container"
|
||||||
|
(map (lambda (row)
|
||||||
|
(div :class "kg-gallery-row"
|
||||||
|
(map (lambda (img-data)
|
||||||
|
(figure :class "kg-gallery-image"
|
||||||
|
(img :src (get img-data "src") :alt (or (get img-data "alt") "") :loading "lazy")
|
||||||
|
(when (get img-data "caption") (figcaption (get img-data "caption")))))
|
||||||
|
row)))
|
||||||
|
images))
|
||||||
|
(when caption (figcaption caption))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; HTML card — wraps user-pasted HTML so the editor can identify the block.
|
||||||
|
;; Content is native sx children (no longer an opaque HTML string).
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-html (&rest children)
|
||||||
|
(div :class "kg-card kg-html-card" children))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Markdown card — rendered markdown content, editor can identify the block.
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-md (&rest children)
|
||||||
|
(div :class "kg-card kg-md-card" children))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Embed card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-embed (&key html caption)
|
||||||
|
(figure :class "kg-card kg-embed-card"
|
||||||
|
(~rich-text :html html)
|
||||||
|
(when caption (figcaption caption))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Bookmark card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-bookmark (&key url title description icon author publisher thumbnail caption)
|
||||||
|
(figure :class "kg-card kg-bookmark-card"
|
||||||
|
(a :class "kg-bookmark-container" :href url
|
||||||
|
(div :class "kg-bookmark-content"
|
||||||
|
(div :class "kg-bookmark-title" (or title ""))
|
||||||
|
(div :class "kg-bookmark-description" (or description ""))
|
||||||
|
(when (or icon author publisher)
|
||||||
|
(span :class "kg-bookmark-metadata"
|
||||||
|
(when icon (img :class "kg-bookmark-icon" :src icon :alt ""))
|
||||||
|
(when author (span :class "kg-bookmark-author" author))
|
||||||
|
(when publisher (span :class "kg-bookmark-publisher" publisher)))))
|
||||||
|
(when thumbnail
|
||||||
|
(div :class "kg-bookmark-thumbnail"
|
||||||
|
(img :src thumbnail :alt ""))))
|
||||||
|
(when caption (figcaption caption))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Callout card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-callout (&key color emoji content)
|
||||||
|
(div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey"))
|
||||||
|
(when emoji (div :class "kg-callout-emoji" emoji))
|
||||||
|
(div :class "kg-callout-text" (or content ""))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Button card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-button (&key url text alignment)
|
||||||
|
(div :class (str "kg-card kg-button-card kg-align-" (or alignment "center"))
|
||||||
|
(a :href url :class "kg-btn kg-btn-accent" (or text ""))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Toggle card (accordion)
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-toggle (&key heading content)
|
||||||
|
(div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close"
|
||||||
|
(div :class "kg-toggle-heading"
|
||||||
|
(h4 :class "kg-toggle-heading-text" (or heading ""))
|
||||||
|
(button :class "kg-toggle-card-icon"
|
||||||
|
(~rich-text :html "<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>")))
|
||||||
|
(div :class "kg-toggle-content" (or content ""))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Audio card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-audio (&key src title duration thumbnail)
|
||||||
|
(div :class "kg-card kg-audio-card"
|
||||||
|
(if thumbnail
|
||||||
|
(img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
|
||||||
|
(div :class "kg-audio-thumbnail placeholder"
|
||||||
|
(~rich-text :html "<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 :class "kg-audio-player-container"
|
||||||
|
(div :class "kg-audio-title" (or title ""))
|
||||||
|
(div :class "kg-audio-player"
|
||||||
|
(button :class "kg-audio-play-icon"
|
||||||
|
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\" fill=\"currentColor\"/></svg>"))
|
||||||
|
(div :class "kg-audio-current-time" "0:00")
|
||||||
|
(div :class "kg-audio-time" (str "/ " (or duration "0:00")))
|
||||||
|
(input :type "range" :class "kg-audio-seek-slider" :max "100" :value "0")
|
||||||
|
(button :class "kg-audio-playback-rate" "1×")
|
||||||
|
(button :class "kg-audio-unmute-icon"
|
||||||
|
(~rich-text :html "<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>"))
|
||||||
|
(input :type "range" :class "kg-audio-volume-slider" :max "100" :value "100")))
|
||||||
|
(audio :src src :preload "metadata")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Video card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-video (&key src caption width thumbnail loop)
|
||||||
|
(figure :class (str "kg-card kg-video-card"
|
||||||
|
(if (= width "wide") " kg-width-wide"
|
||||||
|
(if (= width "full") " kg-width-full" "")))
|
||||||
|
(div :class "kg-video-container"
|
||||||
|
(video :src src :controls true :preload "metadata"
|
||||||
|
:poster (or thumbnail nil) :loop (or loop nil)))
|
||||||
|
(when caption (figcaption caption))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; File card
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-file (&key src filename title filesize caption)
|
||||||
|
(div :class "kg-card kg-file-card"
|
||||||
|
(a :class "kg-file-card-container" :href src :download (or filename "")
|
||||||
|
(div :class "kg-file-card-contents"
|
||||||
|
(div :class "kg-file-card-title" (or title filename ""))
|
||||||
|
(when filesize (div :class "kg-file-card-filesize" filesize)))
|
||||||
|
(div :class "kg-file-card-icon"
|
||||||
|
(~rich-text :html "<svg viewBox=\"0 0 24 24\"><path d=\"M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z\" fill=\"currentColor\"/></svg>")))
|
||||||
|
(when caption (div :class "kg-file-card-caption" caption))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Paywall marker
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
(defcomp ~kg-paywall ()
|
||||||
|
(~rich-text :html "<!--members-only-->"))
|
||||||
26
blog/sx/menu_items.sx
Normal file
26
blog/sx/menu_items.sx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
;; Menu item form and page search components
|
||||||
|
|
||||||
|
(defcomp ~page-search-item (&key id title slug feature-image)
|
||||||
|
(div :class "flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
|
||||||
|
:data-page-id id :data-page-title title :data-page-slug slug
|
||||||
|
:data-page-image (or feature-image "")
|
||||||
|
(if feature-image
|
||||||
|
(img :src feature-image :alt title :class "w-10 h-10 rounded-full object-cover flex-shrink-0")
|
||||||
|
(div :class "w-10 h-10 rounded-full bg-stone-200 flex-shrink-0"))
|
||||||
|
(div :class "flex-1 min-w-0"
|
||||||
|
(div :class "font-medium truncate" title)
|
||||||
|
(div :class "text-xs text-stone-500 truncate" slug))))
|
||||||
|
|
||||||
|
(defcomp ~page-search-results (&key items sentinel)
|
||||||
|
(div :class "border border-stone-200 rounded-md max-h-64 overflow-y-auto"
|
||||||
|
items sentinel))
|
||||||
|
|
||||||
|
(defcomp ~page-search-sentinel (&key url query next-page)
|
||||||
|
(div :sx-get url :sx-trigger "intersect once" :sx-swap "outerHTML"
|
||||||
|
:sx-vals (str "{\"q\": \"" query "\", \"page\": " next-page "}")
|
||||||
|
:class "p-3 text-center text-sm text-stone-400"
|
||||||
|
(i :class "fa fa-spinner fa-spin") " Loading more..."))
|
||||||
|
|
||||||
|
(defcomp ~page-search-empty (&key query)
|
||||||
|
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
|
||||||
|
(str "No pages found matching \"" query "\"")))
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
;; Blog navigation components
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-empty (&key wrapper-id)
|
|
||||||
(div :id wrapper-id :sx-swap-oob "outerHTML"))
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-item-image (&key src label)
|
|
||||||
(if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
|
|
||||||
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img label)
|
|
||||||
(div (a :href href :sx-get hx-get :sx-target "#main-panel"
|
|
||||||
:sx-swap "outerHTML" :sx-push-url "true"
|
|
||||||
:aria-selected selected :class nav-cls
|
|
||||||
img (span label))))
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-item-plain (&key href selected nav-cls img label)
|
|
||||||
(div (a :href href :aria-selected selected :class nav-cls
|
|
||||||
img (span label))))
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items)
|
|
||||||
(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" :sx-swap-oob "outerHTML"
|
|
||||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
|
||||||
:aria-label "Scroll left"
|
|
||||||
:_ left-hs (i :class "fa fa-chevron-left"))
|
|
||||||
(div :id container-id
|
|
||||||
: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;" :_ scroll-hs
|
|
||||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
|
||||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
|
||||||
(button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")
|
|
||||||
:aria-label "Scroll right"
|
|
||||||
:_ right-hs (i :class "fa fa-chevron-right"))))
|
|
||||||
|
|
||||||
;; Nav entries
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-entries-empty ()
|
|
||||||
(div :id "entries-calendars-nav-wrapper" :sx-swap-oob "true"))
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str)
|
|
||||||
(a :href href :class nav-cls
|
|
||||||
(div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")
|
|
||||||
(div :class "flex-1 min-w-0"
|
|
||||||
(div :class "font-medium truncate" name)
|
|
||||||
(div :class "text-xs text-stone-600 truncate" date-str))))
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-calendar-item (&key href nav-cls name)
|
|
||||||
(a :href href :class nav-cls
|
|
||||||
(i :class "fa fa-calendar" :aria-hidden "true")
|
|
||||||
(div name)))
|
|
||||||
|
|
||||||
(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items)
|
|
||||||
(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" :sx-swap-oob "true"
|
|
||||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
|
||||||
:aria-label "Scroll left"
|
|
||||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"
|
|
||||||
(i :class "fa fa-chevron-left"))
|
|
||||||
(div :id "associated-items-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;" :_ scroll-hs
|
|
||||||
(div :class "flex flex-col sm:flex-row gap-1" items))
|
|
||||||
(style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")
|
|
||||||
(button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
|
||||||
:aria-label "Scroll right"
|
|
||||||
:_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
|
|
||||||
(i :class "fa fa-chevron-right"))))
|
|
||||||
@@ -18,33 +18,11 @@
|
|||||||
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
(i :class "fa fa-shopping-bag text-green-600 mr-1")
|
||||||
" Market \u2014 enable product catalog on this page"))))
|
" Market \u2014 enable product catalog on this page"))))
|
||||||
|
|
||||||
(defcomp ~blog-sumup-connected ()
|
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
|
||||||
(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected"))
|
|
||||||
|
|
||||||
(defcomp ~blog-sumup-key-hint ()
|
|
||||||
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
|
|
||||||
|
|
||||||
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint checkout-prefix connected)
|
|
||||||
(div :class "mt-4 pt-4 border-t border-stone-100"
|
(div :class "mt-4 pt-4 border-t border-stone-100"
|
||||||
(h4 :class "text-sm font-medium text-stone-700"
|
(~sumup-settings-form :update-url sumup-url :merchant-code merchant-code
|
||||||
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
|
:placeholder placeholder :sumup-configured sumup-configured
|
||||||
(p :class "text-xs text-stone-400 mt-1 mb-3"
|
:checkout-prefix checkout-prefix :panel-id "features-panel")))
|
||||||
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
|
|
||||||
(form :sx-put sumup-url :sx-target "#features-panel" :sx-swap "outerHTML" :class "space-y-3"
|
|
||||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
|
|
||||||
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
|
|
||||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
|
||||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
|
|
||||||
(input :type "password" :name "api_key" :value "" :placeholder placeholder
|
|
||||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
|
|
||||||
key-hint)
|
|
||||||
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
|
|
||||||
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
|
|
||||||
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
|
|
||||||
(button :type "submit"
|
|
||||||
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
|
|
||||||
"Save SumUp Settings")
|
|
||||||
connected)))
|
|
||||||
|
|
||||||
(defcomp ~blog-features-panel (&key form sumup)
|
(defcomp ~blog-features-panel (&key form sumup)
|
||||||
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
278
blog/sxc/pages/__init__.py
Normal file
278
blog/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""Blog defpage setup — registers layouts, page helpers, and loads .sx pages."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def setup_blog_pages() -> None:
|
||||||
|
"""Register blog-specific layouts, page helpers, and load page definitions."""
|
||||||
|
_register_blog_layouts()
|
||||||
|
_register_blog_helpers()
|
||||||
|
_load_blog_page_files()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_blog_page_files() -> None:
|
||||||
|
import os
|
||||||
|
from shared.sx.pages import load_page_dir
|
||||||
|
load_page_dir(os.path.dirname(__file__), "blog")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layouts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_blog_layouts() -> None:
|
||||||
|
from shared.sx.layouts import register_custom_layout
|
||||||
|
# :blog — root + blog header (for new-post, new-page)
|
||||||
|
register_custom_layout("blog", _blog_full, _blog_oob)
|
||||||
|
# :blog-settings — root + settings header (with settings nav menu)
|
||||||
|
register_custom_layout("blog-settings", _settings_full, _settings_oob,
|
||||||
|
mobile_fn=_settings_mobile)
|
||||||
|
# Sub-settings layouts (root + settings + sub header)
|
||||||
|
register_custom_layout("blog-cache", _cache_full, _cache_oob)
|
||||||
|
register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
|
||||||
|
register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
|
||||||
|
register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
|
||||||
|
register_custom_layout("blog-tag-group-edit",
|
||||||
|
_tag_group_edit_full, _tag_group_edit_oob)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Blog layout (root + blog header) ---
|
||||||
|
|
||||||
|
def _blog_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
from shared.sx.helpers import root_header_sx
|
||||||
|
from sx.sx_components import _blog_header_sx
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
blog_hdr = _blog_header_sx(ctx)
|
||||||
|
return "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _blog_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||||
|
from sx.sx_components import _blog_header_sx
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
blog_hdr = _blog_header_sx(ctx)
|
||||||
|
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||||
|
return oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Settings layout (root + settings header) ---
|
||||||
|
|
||||||
|
def _settings_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
from shared.sx.helpers import root_header_sx
|
||||||
|
from sx.sx_components import _settings_header_sx
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
settings_hdr = _settings_header_sx(ctx)
|
||||||
|
return "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||||
|
from sx.sx_components import _settings_header_sx
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
settings_hdr = _settings_header_sx(ctx)
|
||||||
|
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||||
|
return oob_header_sx("root-header-child", "root-settings-header-child", rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_mobile(ctx: dict, **kw: Any) -> str:
|
||||||
|
from sx.sx_components import _settings_nav_sx
|
||||||
|
return _settings_nav_sx(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sub-settings helpers ---
|
||||||
|
|
||||||
|
def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
|
||||||
|
endpoint: str, icon: str, label: str) -> str:
|
||||||
|
from shared.sx.helpers import root_header_sx
|
||||||
|
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||||
|
from quart import url_for as qurl
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
settings_hdr = _settings_header_sx(ctx)
|
||||||
|
sub_hdr = _sub_settings_header_sx(row_id, child_id,
|
||||||
|
qurl(endpoint), icon, label, ctx)
|
||||||
|
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
|
||||||
|
endpoint: str, icon: str, label: str) -> str:
|
||||||
|
from shared.sx.helpers import oob_header_sx
|
||||||
|
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||||
|
from quart import url_for as qurl
|
||||||
|
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||||
|
sub_hdr = _sub_settings_header_sx(row_id, child_id,
|
||||||
|
qurl(endpoint), icon, label, ctx)
|
||||||
|
sub_oob = oob_header_sx("root-settings-header-child", child_id, sub_hdr)
|
||||||
|
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Cache ---
|
||||||
|
|
||||||
|
def _cache_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_full(ctx, "cache-row", "cache-header-child",
|
||||||
|
"settings.defpage_cache_page", "refresh", "Cache")
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
|
||||||
|
"settings.defpage_cache_page", "refresh", "Cache")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Snippets ---
|
||||||
|
|
||||||
|
def _snippets_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
|
||||||
|
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||||
|
|
||||||
|
|
||||||
|
def _snippets_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
|
||||||
|
"snippets.defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Menu Items ---
|
||||||
|
|
||||||
|
def _menu_items_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
|
||||||
|
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
|
||||||
|
|
||||||
|
|
||||||
|
def _menu_items_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
|
||||||
|
"menu_items.defpage_menu_items_page", "bars", "Menu Items")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tag Groups ---
|
||||||
|
|
||||||
|
def _tag_groups_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||||
|
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
return _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||||
|
"blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tag Group Edit ---
|
||||||
|
|
||||||
|
def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
from quart import request
|
||||||
|
g_id = (request.view_args or {}).get("id")
|
||||||
|
from quart import url_for as qurl
|
||||||
|
from shared.sx.helpers import root_header_sx
|
||||||
|
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
settings_hdr = _settings_header_sx(ctx)
|
||||||
|
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||||
|
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
|
||||||
|
"tags", "Tag Groups", ctx)
|
||||||
|
return "(<> " + root_hdr + " " + settings_hdr + " " + sub_hdr + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
from quart import request
|
||||||
|
g_id = (request.view_args or {}).get("id")
|
||||||
|
from quart import url_for as qurl
|
||||||
|
from shared.sx.helpers import oob_header_sx
|
||||||
|
from sx.sx_components import _settings_header_sx, _sub_settings_header_sx
|
||||||
|
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||||
|
sub_hdr = _sub_settings_header_sx("tag-groups-row", "tag-groups-header-child",
|
||||||
|
qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
|
||||||
|
"tags", "Tag Groups", ctx)
|
||||||
|
sub_oob = oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
|
||||||
|
return "(<> " + settings_hdr_oob + " " + sub_oob + ")"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page helpers (sync functions available in .sx defpage expressions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_blog_helpers() -> None:
|
||||||
|
from shared.sx.pages import register_page_helpers
|
||||||
|
register_page_helpers("blog", {
|
||||||
|
"editor-content": _h_editor_content,
|
||||||
|
"editor-page-content": _h_editor_page_content,
|
||||||
|
"post-admin-content": _h_post_admin_content,
|
||||||
|
"post-data-content": _h_post_data_content,
|
||||||
|
"post-preview-content": _h_post_preview_content,
|
||||||
|
"post-entries-content": _h_post_entries_content,
|
||||||
|
"post-settings-content": _h_post_settings_content,
|
||||||
|
"post-edit-content": _h_post_edit_content,
|
||||||
|
"settings-content": _h_settings_content,
|
||||||
|
"cache-content": _h_cache_content,
|
||||||
|
"snippets-content": _h_snippets_content,
|
||||||
|
"menu-items-content": _h_menu_items_content,
|
||||||
|
"tag-groups-content": _h_tag_groups_content,
|
||||||
|
"tag-group-edit-content": _h_tag_group_edit_content,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _h_editor_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "editor_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_editor_page_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "editor_page_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_post_admin_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "post_admin_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_post_data_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "post_data_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_post_preview_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "post_preview_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_post_entries_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "post_entries_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_post_settings_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "post_settings_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_post_edit_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "post_edit_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_settings_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "settings_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_cache_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "cache_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_snippets_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "snippets_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_menu_items_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "menu_items_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_tag_groups_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "tag_groups_content", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _h_tag_group_edit_content():
|
||||||
|
from quart import g
|
||||||
|
return getattr(g, "tag_group_edit_content", "")
|
||||||
98
blog/sxc/pages/blog.sx
Normal file
98
blog/sxc/pages/blog.sx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
; Blog app defpage declarations
|
||||||
|
; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
|
||||||
|
|
||||||
|
; --- New post/page editors ---
|
||||||
|
|
||||||
|
(defpage new-post
|
||||||
|
:path "/new/"
|
||||||
|
:auth :admin
|
||||||
|
:layout :blog
|
||||||
|
:content (editor-content))
|
||||||
|
|
||||||
|
(defpage new-page
|
||||||
|
:path "/new-page/"
|
||||||
|
:auth :admin
|
||||||
|
:layout :blog
|
||||||
|
:content (editor-page-content))
|
||||||
|
|
||||||
|
; --- Post admin pages (nested under /<slug>/admin/) ---
|
||||||
|
|
||||||
|
(defpage post-admin
|
||||||
|
:path "/"
|
||||||
|
:auth :admin
|
||||||
|
:layout (:post-admin :selected "admin")
|
||||||
|
:content (post-admin-content))
|
||||||
|
|
||||||
|
(defpage post-data
|
||||||
|
:path "/data/"
|
||||||
|
:auth :admin
|
||||||
|
:layout (:post-admin :selected "data")
|
||||||
|
:content (post-data-content))
|
||||||
|
|
||||||
|
(defpage post-preview
|
||||||
|
:path "/preview/"
|
||||||
|
:auth :admin
|
||||||
|
:layout (:post-admin :selected "preview")
|
||||||
|
:content (post-preview-content))
|
||||||
|
|
||||||
|
(defpage post-entries
|
||||||
|
:path "/entries/"
|
||||||
|
:auth :admin
|
||||||
|
:layout (:post-admin :selected "entries")
|
||||||
|
:content (post-entries-content))
|
||||||
|
|
||||||
|
(defpage post-settings
|
||||||
|
:path "/settings/"
|
||||||
|
:auth :post_author
|
||||||
|
:layout (:post-admin :selected "settings")
|
||||||
|
:content (post-settings-content))
|
||||||
|
|
||||||
|
(defpage post-edit
|
||||||
|
:path "/edit/"
|
||||||
|
:auth :post_author
|
||||||
|
:layout (:post-admin :selected "edit")
|
||||||
|
:content (post-edit-content))
|
||||||
|
|
||||||
|
; --- Settings pages ---
|
||||||
|
|
||||||
|
(defpage settings-home
|
||||||
|
:path "/"
|
||||||
|
:auth :admin
|
||||||
|
:layout :blog-settings
|
||||||
|
:content (settings-content))
|
||||||
|
|
||||||
|
(defpage cache-page
|
||||||
|
:path "/cache/"
|
||||||
|
:auth :admin
|
||||||
|
:layout :blog-cache
|
||||||
|
:content (cache-content))
|
||||||
|
|
||||||
|
; --- Snippets ---
|
||||||
|
|
||||||
|
(defpage snippets-page
|
||||||
|
:path "/"
|
||||||
|
:auth :login
|
||||||
|
:layout :blog-snippets
|
||||||
|
:content (snippets-content))
|
||||||
|
|
||||||
|
; --- Menu Items ---
|
||||||
|
|
||||||
|
(defpage menu-items-page
|
||||||
|
:path "/"
|
||||||
|
:auth :admin
|
||||||
|
:layout :blog-menu-items
|
||||||
|
:content (menu-items-content))
|
||||||
|
|
||||||
|
; --- Tag Groups ---
|
||||||
|
|
||||||
|
(defpage tag-groups-page
|
||||||
|
:path "/"
|
||||||
|
:auth :admin
|
||||||
|
:layout :blog-tag-groups
|
||||||
|
:content (tag-groups-content))
|
||||||
|
|
||||||
|
(defpage tag-group-edit
|
||||||
|
:path "/<int:id>/"
|
||||||
|
:auth :admin
|
||||||
|
:layout :blog-tag-group-edit
|
||||||
|
:content (tag-group-edit-content))
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head><meta charset="utf-8"></head>
|
|
||||||
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
|
|
||||||
<tr><td align="center">
|
|
||||||
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
|
|
||||||
<tr><td>
|
|
||||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
|
|
||||||
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
|
|
||||||
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
|
|
||||||
Click the button below to sign in. This link will expire in 15 minutes.
|
|
||||||
</p>
|
|
||||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
|
|
||||||
<a href="{{ link_url }}" target="_blank"
|
|
||||||
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
|
|
||||||
Sign in
|
|
||||||
</a>
|
|
||||||
</td></tr></table>
|
|
||||||
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
|
|
||||||
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
|
|
||||||
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
|
|
||||||
</p>
|
|
||||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
|
|
||||||
<p style="margin:0;font-size:12px;color:#a8a29e;">
|
|
||||||
If you did not request this email, you can safely ignore it.
|
|
||||||
</p>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
{# New Post/Page + Drafts toggle — shown in aside (desktop + mobile) #}
|
|
||||||
<div class="flex flex-wrap gap-2 px-4 py-3">
|
|
||||||
{% if has_access('blog.new_post') %}
|
|
||||||
{% set new_href = url_for('blog.new_post')|host %}
|
|
||||||
<a
|
|
||||||
href="{{ new_href }}"
|
|
||||||
sx-get="{{ new_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-push-url="true"
|
|
||||||
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
|
|
||||||
title="New Post"
|
|
||||||
>
|
|
||||||
<i class="fa fa-plus mr-1"></i> New Post
|
|
||||||
</a>
|
|
||||||
{% set new_page_href = url_for('blog.new_page')|host %}
|
|
||||||
<a
|
|
||||||
href="{{ new_page_href }}"
|
|
||||||
sx-get="{{ new_page_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-push-url="true"
|
|
||||||
class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
|
||||||
title="New Page"
|
|
||||||
>
|
|
||||||
<i class="fa fa-plus mr-1"></i> New Page
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if g.user and (draft_count or drafts) %}
|
|
||||||
{% if drafts %}
|
|
||||||
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
|
|
||||||
<a
|
|
||||||
href="{{ drafts_off_href }}"
|
|
||||||
sx-get="{{ drafts_off_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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 }}"
|
|
||||||
sx-get="{{ drafts_on_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
{% 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 }}"
|
|
||||||
sx-get="{{ _href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select ="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-push-url="true"
|
|
||||||
aria-selected="{{ 'true' if _active else 'false' }}"
|
|
||||||
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
|
||||||
>
|
|
||||||
<header class="mb-2 text-center">
|
|
||||||
<h2 class="text-4xl font-bold text-stone-900">
|
|
||||||
{{ post.title }}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{% if post.status == "draft" %}
|
|
||||||
<div class="flex justify-center gap-2 mt-1">
|
|
||||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
|
|
||||||
{% if post.publish_requested %}
|
|
||||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if post.updated_at %}
|
|
||||||
<p class="text-sm text-stone-500">
|
|
||||||
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% elif post.published_at %}
|
|
||||||
<p class="text-sm text-stone-500">
|
|
||||||
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if post.feature_image %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<img
|
|
||||||
src="{{ post.feature_image }}"
|
|
||||||
alt=""
|
|
||||||
class="rounded-lg w-full object-cover"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if post.custom_excerpt %}
|
|
||||||
<p class="text-stone-700 text-lg leading-relaxed text-center">
|
|
||||||
{{ post.custom_excerpt }}
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
{% if post.excerpt %}
|
|
||||||
<p class="text-stone-700 text-lg leading-relaxed text-center">
|
|
||||||
{{ post.excerpt }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{# Card decorations — via fragments #}
|
|
||||||
{% if card_widgets_html %}
|
|
||||||
{% set _card_html = card_widgets_html.get(post.id|string, "") %}
|
|
||||||
{% if _card_html %}{{ _card_html | safe }}{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% include '_types/blog/_card/at_bar.html' %}
|
|
||||||
|
|
||||||
</article>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{# --- 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 --- #}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{% 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 --- #}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<article class="relative">
|
|
||||||
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
|
|
||||||
<a
|
|
||||||
href="{{ _href }}"
|
|
||||||
sx-get="{{ _href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select ="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{% 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"
|
|
||||||
sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
|
|
||||||
sx-trigger="intersect once delay:250ms, sentinelmobile:retry"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-media="(max-width: 767px)"
|
|
||||||
sx-retry="exponential:1000:30000"
|
|
||||||
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"
|
|
||||||
sx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
|
|
||||||
sx-trigger="intersect once delay:250ms, sentinel:retry"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-retry="exponential:1000:30000"
|
|
||||||
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 %}
|
|
||||||
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
|
|
||||||
{# Content type tabs: Posts | Pages #}
|
|
||||||
<div class="flex justify-center gap-1 px-3 pt-3">
|
|
||||||
{% set posts_href = (url_for('blog.index'))|host %}
|
|
||||||
{% set pages_href = (url_for('blog.index') ~ '?type=pages')|host %}
|
|
||||||
<a
|
|
||||||
href="{{ posts_href }}"
|
|
||||||
sx-get="{{ posts_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{ hx_select_search }}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-push-url="true"
|
|
||||||
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
|
|
||||||
{{ 'bg-stone-700 text-white' if content_type != 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
|
|
||||||
>Posts</a>
|
|
||||||
<a
|
|
||||||
href="{{ pages_href }}"
|
|
||||||
sx-get="{{ pages_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{ hx_select_search }}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-push-url="true"
|
|
||||||
class="px-4 py-1.5 rounded-t text-sm font-medium transition-colors
|
|
||||||
{{ 'bg-stone-700 text-white' if content_type == 'pages' else 'bg-stone-100 text-stone-600 hover:bg-stone-200' }}"
|
|
||||||
>Pages</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if content_type == 'pages' %}
|
|
||||||
{# Pages listing #}
|
|
||||||
<div class="max-w-full px-3 py-3 space-y-3">
|
|
||||||
{% set page_num = page %}
|
|
||||||
{% include "_types/blog/_page_cards.html" %}
|
|
||||||
</div>
|
|
||||||
<div class="pb-8"></div>
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
{# View toggle bar - desktop only #}
|
|
||||||
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
|
|
||||||
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
|
|
||||||
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
|
|
||||||
<a
|
|
||||||
href="{{ list_href }}"
|
|
||||||
sx-get="{{ list_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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"
|
|
||||||
onclick="localStorage.removeItem('blog_view')"
|
|
||||||
>
|
|
||||||
<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 }}"
|
|
||||||
sx-get="{{ tile_href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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"
|
|
||||||
onclick="localStorage.setItem('blog_view','tile')"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Cards container - list or grid based on view #}
|
|
||||||
{% if view == 'tile' %}
|
|
||||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{% include "_types/blog/_cards.html" %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="max-w-full px-3 py-3 space-y-3">
|
|
||||||
{% include "_types/blog/_cards.html" %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="pb-8"></div>
|
|
||||||
{% endif %}{# end content_type check #}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{# Single page card for pages listing #}
|
|
||||||
<article class="border-b pb-6 last:border-b-0 relative">
|
|
||||||
{% set _href = url_for('blog.post.post_detail', slug=page.slug)|host %}
|
|
||||||
<a
|
|
||||||
href="{{ _href }}"
|
|
||||||
sx-get="{{ _href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{ hx_select_search }}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-push-url="true"
|
|
||||||
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
|
|
||||||
>
|
|
||||||
<header class="mb-2 text-center">
|
|
||||||
<h2 class="text-4xl font-bold text-stone-900">
|
|
||||||
{{ page.title }}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{# Feature badges #}
|
|
||||||
{% if page.features %}
|
|
||||||
<div class="flex justify-center gap-2 mt-2">
|
|
||||||
{% if page.features.get('calendar') %}
|
|
||||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
|
|
||||||
<i class="fa fa-calendar mr-1"></i>Calendar
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if page.features.get('market') %}
|
|
||||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">
|
|
||||||
<i class="fa fa-shopping-bag mr-1"></i>Market
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page.published_at %}
|
|
||||||
<p class="text-sm text-stone-500">
|
|
||||||
Published: {{ page.published_at.strftime("%-d %b %Y at %H:%M") }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if page.feature_image %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<img
|
|
||||||
src="{{ page.feature_image }}"
|
|
||||||
alt=""
|
|
||||||
class="rounded-lg w-full object-cover"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if page.custom_excerpt or page.excerpt %}
|
|
||||||
<p class="text-stone-700 text-lg leading-relaxed text-center">
|
|
||||||
{{ page.custom_excerpt or page.excerpt }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{# Page cards loop with pagination sentinel #}
|
|
||||||
{% for page in pages %}
|
|
||||||
{% include "_types/blog/_page_card.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if page_num < total_pages|int %}
|
|
||||||
<div
|
|
||||||
id="sentinel-{{ page_num }}-d"
|
|
||||||
class="h-4 opacity-0 pointer-events-none"
|
|
||||||
sx-get="{{ (current_local_href ~ {'page': page_num + 1}|qs)|host }}"
|
|
||||||
sx-trigger="intersect once delay:250ms"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
></div>
|
|
||||||
{% else %}
|
|
||||||
{% if pages %}
|
|
||||||
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="col-span-full mt-8 text-center text-stone-500">No pages found.</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% from 'macros/search.html' import search_desktop %}
|
|
||||||
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
|
|
||||||
{% include '_types/blog/_action_buttons.html' %}
|
|
||||||
<div
|
|
||||||
id="category-summary-desktop"
|
|
||||||
hxx-swap-oob="outerHTML"
|
|
||||||
>
|
|
||||||
{% include '_types/blog/desktop/menu/tag_groups.html' %}
|
|
||||||
{% include '_types/blog/desktop/menu/authors.html' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="filter-summary-desktop"
|
|
||||||
hxx-swap-oob="outerHTML"
|
|
||||||
>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{% 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 }}"
|
|
||||||
sx-get="{{ href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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 }}"
|
|
||||||
sx-get="{{ href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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>
|
|
||||||
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{# 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 }}"
|
|
||||||
sx-get="{{ href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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 }}"
|
|
||||||
sx-get="{{ href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{% 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 }}"
|
|
||||||
sx-get="{{ href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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 }}"
|
|
||||||
sx-get="{{ href }}"
|
|
||||||
sx-target="#main-panel"
|
|
||||||
sx-select="{{hx_select_search}}"
|
|
||||||
sx-swap="outerHTML"
|
|
||||||
sx-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>
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
|
|
||||||
{% import 'macros/links.html' as links %}
|
|
||||||
{% macro header_row(oob=False) %}
|
|
||||||
{% call links.menu_row(id='blog-row', oob=oob) %}
|
|
||||||
<div></div>
|
|
||||||
{% endcall %}
|
|
||||||
{% endmacro %}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user