Compare commits
16 Commits
4098c32878
...
sx
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f0929fdf | |||
| f551fc7453 | |||
| e30cb0a992 | |||
| 293f7713d6 | |||
| 4ba63bda17 | |||
| 0a81a2af01 | |||
| 0c9dbd6657 | |||
| a4377668be | |||
| a98354c0f0 | |||
| df8b19ccb8 | |||
| 544892edd9 | |||
| c243d17eeb | |||
| 5b4cacaf19 | |||
| a8c0741f54 | |||
| 0af07f9f2e | |||
| 222738546a |
@@ -8,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_account_bp, register_auth_bp, register_fragments
|
||||
from bp import register_account_bp, register_auth_bp
|
||||
|
||||
|
||||
async def account_context() -> dict:
|
||||
@@ -72,10 +72,22 @@ def create_app() -> "Quart":
|
||||
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 ---
|
||||
app.register_blueprint(register_auth_bp())
|
||||
app.register_blueprint(register_account_bp())
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
account_bp = register_account_bp()
|
||||
app.register_blueprint(account_bp)
|
||||
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "account")
|
||||
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "account")
|
||||
|
||||
from bp.actions.routes import register as register_actions
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from .account.routes import register as register_account_bp
|
||||
from .auth.routes import register as register_auth_bp
|
||||
from .fragments import register_fragments
|
||||
|
||||
@@ -1,104 +1,34 @@
|
||||
"""Account pages blueprint.
|
||||
|
||||
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 quart import (
|
||||
Blueprint,
|
||||
request,
|
||||
make_response,
|
||||
redirect,
|
||||
g,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
from shared.infrastructure.urls import login_url
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
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="/"):
|
||||
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
|
||||
|
||||
@account_bp.context_processor
|
||||
async def context():
|
||||
@account_bp.before_request
|
||||
async def _prepare_page_data():
|
||||
"""Fetch account_nav fragments for layout."""
|
||||
events_nav, cart_nav, artdag_nav = await fetch_fragments([
|
||||
("events", "account-nav-item", {}),
|
||||
("cart", "account-nav-item", {}),
|
||||
("artdag", "nav-item", {}),
|
||||
], required=False)
|
||||
return {"oob": oob, "account_nav": events_nav + cart_nav + artdag_nav}
|
||||
|
||||
@account_bp.get("/")
|
||||
async def account():
|
||||
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"):
|
||||
return redirect(login_url("/"))
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_account_page(ctx)
|
||||
return await make_response(html)
|
||||
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,
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": nl,
|
||||
"un": un,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_newsletters_page, render_newsletters_oob
|
||||
|
||||
ctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_newsletters_page(ctx, newsletter_list)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_newsletters_oob(ctx, newsletter_list)
|
||||
return sx_response(sx_src)
|
||||
g.account_nav = events_nav + cart_nav + artdag_nav
|
||||
|
||||
@account_bp.post("/newsletter/<int:newsletter_id>/toggle/")
|
||||
async def toggle_newsletter(newsletter_id: int):
|
||||
@@ -128,31 +58,4 @@ def register(url_prefix="/"):
|
||||
from sx.sx_components import render_newsletter_toggle
|
||||
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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Account app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``account/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("account", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "account", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -12,7 +12,7 @@ from typing import Any
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import (
|
||||
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 + handlers at import time
|
||||
@@ -238,88 +238,8 @@ def _device_approved_content() -> str:
|
||||
# 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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API: Newsletters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
||||
"""Full page: newsletters."""
|
||||
main = _newsletters_panel_sx(ctx, newsletter_list)
|
||||
|
||||
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_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: str) -> str:
|
||||
"""Full page: fragment-provided content.
|
||||
|
||||
*page_fragment* may be sx source (from text/sx fragments wrapped in
|
||||
SxExpr) or HTML (from text/html fragments). Sx source is embedded
|
||||
directly; HTML is wrapped in ``~rich-text``.
|
||||
"""
|
||||
from shared.sx.parser import SxExpr
|
||||
hdr = root_header_sx(ctx)
|
||||
hdr_child = header_child_sx(_auth_header_sx(ctx))
|
||||
header_rows = "(<> " + hdr + " " + hdr_child + ")"
|
||||
|
||||
content = _fragment_content(page_fragment)
|
||||
return full_page_sx(ctx, header_rows=header_rows,
|
||||
content=content,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
async def render_fragment_oob(ctx: dict, page_fragment: str) -> str:
|
||||
"""OOB response for fragment pages."""
|
||||
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
|
||||
|
||||
content = _fragment_content(page_fragment)
|
||||
return oob_page_sx(oobs=oobs,
|
||||
content=content,
|
||||
menu=_auth_nav_mobile_sx(ctx))
|
||||
|
||||
|
||||
def _fragment_content(frag: object) -> str:
|
||||
|
||||
134
account/sxc/pages/__init__.py
Normal file
134
account/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""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(**kw):
|
||||
from sx.sx_components import _account_main_panel_sx
|
||||
return _account_main_panel_sx({})
|
||||
|
||||
|
||||
async def _h_newsletters_content(**kw):
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models import UserNewsletter
|
||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()}
|
||||
|
||||
newsletter_list = []
|
||||
for nl in all_newsletters:
|
||||
un = user_subs.get(nl.id)
|
||||
newsletter_list.append({
|
||||
"newsletter": nl,
|
||||
"un": un,
|
||||
"subscribed": un.subscribed if un else False,
|
||||
})
|
||||
|
||||
if not newsletter_list:
|
||||
from shared.sx.helpers import sx_call
|
||||
return sx_call("account-newsletter-empty")
|
||||
from sx.sx_components import _newsletters_panel_sx
|
||||
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, newsletter_list)
|
||||
|
||||
|
||||
async def _h_fragment_content(slug=None, **kw):
|
||||
from quart import g, abort
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
if not slug or not g.get("user"):
|
||||
return ""
|
||||
fragment_html = await fetch_fragment(
|
||||
"events", "account-page",
|
||||
params={"slug": slug, "user_id": str(g.user.id)},
|
||||
)
|
||||
if not fragment_html:
|
||||
abort(404)
|
||||
from sx.sx_components import _fragment_content
|
||||
return _fragment_content(fragment_html)
|
||||
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 slug))
|
||||
@@ -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 %}
|
||||
25
blog/app.py
25
blog/app.py
@@ -16,10 +16,10 @@ from bp import (
|
||||
register_admin,
|
||||
register_menu_items,
|
||||
register_snippets,
|
||||
register_fragments,
|
||||
register_data,
|
||||
register_actions,
|
||||
)
|
||||
from sxc.pages import setup_blog_pages
|
||||
|
||||
|
||||
async def blog_context() -> dict:
|
||||
@@ -80,6 +80,8 @@ async def blog_context() -> dict:
|
||||
def create_app() -> "Quart":
|
||||
from services import register_domain_services
|
||||
|
||||
setup_blog_pages()
|
||||
|
||||
app = create_base_app(
|
||||
"blog",
|
||||
context_fn=blog_context,
|
||||
@@ -105,7 +107,9 @@ def create_app() -> "Quart":
|
||||
app.register_blueprint(register_admin("/settings"))
|
||||
app.register_blueprint(register_menu_items())
|
||||
app.register_blueprint(register_snippets())
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "blog")
|
||||
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
@@ -159,6 +163,23 @@ def create_app() -> "Quart":
|
||||
)
|
||||
return jsonify(resp)
|
||||
|
||||
# Auto-mount all defpages with absolute paths
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "blog")
|
||||
|
||||
# --- Pass defpage helper data to template context for layouts ---
|
||||
@app.context_processor
|
||||
async def inject_blog_data():
|
||||
import os
|
||||
from shared.config import config as get_config
|
||||
ctx = {
|
||||
"blog_title": get_config()["blog_title"],
|
||||
"base_title": get_config()["title"],
|
||||
"unsplash_api_key": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
}
|
||||
ctx.update(getattr(g, '_defpage_ctx', {}))
|
||||
return ctx
|
||||
|
||||
# --- debug: url rules ---
|
||||
@app.get("/__rules")
|
||||
async def dump_rules():
|
||||
|
||||
@@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp
|
||||
from .admin.routes import register as register_admin
|
||||
from .menu_items.routes import register as register_menu_items
|
||||
from .snippets.routes import register as register_snippets
|
||||
from .fragments import register_fragments
|
||||
from .data import register_data
|
||||
from .actions.routes import register as register_actions
|
||||
|
||||
@@ -3,13 +3,9 @@ from __future__ import annotations
|
||||
#from quart import Blueprint, g
|
||||
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
redirect,
|
||||
url_for,
|
||||
request,
|
||||
jsonify
|
||||
)
|
||||
from shared.browser.app.redis_cacher import clear_all_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
@@ -27,34 +23,6 @@ def register(url_prefix):
|
||||
"base_title": f"{config()['title']} settings",
|
||||
}
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def home():
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_settings_page, render_settings_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
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/")
|
||||
@require_admin
|
||||
async def cache_clear():
|
||||
@@ -65,7 +33,7 @@ def register(url_prefix):
|
||||
html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S"))
|
||||
return sx_response(html)
|
||||
|
||||
return redirect(url_for("settings.cache"))
|
||||
return redirect(url_for("defpage_cache_page"))
|
||||
return bp
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
redirect,
|
||||
url_for,
|
||||
@@ -13,9 +11,7 @@ from quart import (
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.redis_cacher import invalidate_tag_cache
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
from models.tag_group import TagGroup, TagGroupTag
|
||||
from models.ghost_content import Tag
|
||||
@@ -46,35 +42,13 @@ async def _unassigned_tags(session):
|
||||
def register():
|
||||
bp = Blueprint("tag_groups_admin", __name__, url_prefix="/settings/tag-groups")
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def index():
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
unassigned = await _unassigned_tags(g.s)
|
||||
|
||||
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
||||
|
||||
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("/")
|
||||
@require_admin
|
||||
async def create():
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
if not name:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
slug = _slugify(name)
|
||||
feature_image = (form.get("feature_image") or "").strip() or None
|
||||
@@ -90,55 +64,14 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
|
||||
@bp.get("/<int:id>/")
|
||||
@require_admin
|
||||
async def edit(id: int):
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
|
||||
# Assigned tag IDs for this group
|
||||
assigned_rows = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == id)
|
||||
)).scalars()
|
||||
)
|
||||
assigned_tag_ids = set(assigned_rows)
|
||||
|
||||
# All public, non-deleted tags
|
||||
all_tags = list(
|
||||
(await g.s.execute(
|
||||
select(Tag).where(
|
||||
Tag.deleted_at.is_(None),
|
||||
(Tag.visibility == "public") | (Tag.visibility.is_(None)),
|
||||
).order_by(Tag.name)
|
||||
)).scalars()
|
||||
)
|
||||
|
||||
ctx = {
|
||||
"group": tg,
|
||||
"all_tags": all_tags,
|
||||
"assigned_tag_ids": assigned_tag_ids,
|
||||
}
|
||||
|
||||
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))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
@bp.post("/<int:id>/")
|
||||
@require_admin
|
||||
async def save(id: int):
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
form = await request.form
|
||||
name = (form.get("name") or "").strip()
|
||||
@@ -169,7 +102,7 @@ def register():
|
||||
await g.s.flush()
|
||||
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.edit", id=id))
|
||||
return redirect(url_for("defpage_tag_group_edit", id=id))
|
||||
|
||||
@bp.post("/<int:id>/delete/")
|
||||
@require_admin
|
||||
@@ -179,6 +112,6 @@ def register():
|
||||
await g.s.delete(tg)
|
||||
await g.s.flush()
|
||||
await invalidate_tag_cache("blog")
|
||||
return redirect(url_for("blog.tag_groups_admin.index"))
|
||||
return redirect(url_for("defpage_tag_groups_page"))
|
||||
|
||||
return bp
|
||||
|
||||
@@ -51,10 +51,9 @@ def register(url_prefix, title):
|
||||
pass
|
||||
|
||||
@blogs_bp.before_request
|
||||
def route():
|
||||
async def route():
|
||||
g.makeqs_factory = makeqs_factory
|
||||
|
||||
|
||||
@blogs_bp.context_processor
|
||||
async def inject_root():
|
||||
return {
|
||||
@@ -215,21 +214,6 @@ def register(url_prefix, title):
|
||||
sx_src = await render_blog_oob(tctx)
|
||||
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/")
|
||||
@require_admin
|
||||
async def new_post_save():
|
||||
@@ -283,25 +267,9 @@ def register(url_prefix, title):
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the edit page
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=post.slug)))
|
||||
return redirect(host_url(url_for("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/")
|
||||
@require_admin
|
||||
async def new_page_save():
|
||||
@@ -357,7 +325,7 @@ def register(url_prefix, title):
|
||||
await invalidate_tag_cache("blog")
|
||||
|
||||
# Redirect to the page admin
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=page.slug)))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=page.slug)))
|
||||
|
||||
|
||||
@blogs_bp.get("/drafts/")
|
||||
|
||||
@@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile(
|
||||
def _parse_card_fragments(html: str) -> dict[str, str]:
|
||||
"""Parse the container-cards fragment into {post_id_str: html} dict."""
|
||||
result = {}
|
||||
for m in _CARD_MARKER_RE.finditer(html):
|
||||
for m in _CARD_MARKER_RE.finditer(str(html)):
|
||||
post_id_str = m.group(1)
|
||||
inner = m.group(2).strip()
|
||||
if inner:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Blog app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
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 quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("blog", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "blog", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -12,7 +12,6 @@ from .services.menu_items import (
|
||||
search_pages,
|
||||
MenuItemError,
|
||||
)
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
|
||||
def register():
|
||||
@@ -23,25 +22,6 @@ def register():
|
||||
from sx.sx_components import render_menu_items_nav_oob
|
||||
return render_menu_items_nav_oob(menu_items)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def list_menu_items():
|
||||
"""List all menu items"""
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_menu_items_page, render_menu_items_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
if not is_htmx_request():
|
||||
html = await render_menu_items_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_menu_items_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/new/")
|
||||
@require_admin
|
||||
async def new_menu_item():
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
|
||||
from quart import (
|
||||
render_template,
|
||||
make_response,
|
||||
Blueprint,
|
||||
g,
|
||||
@@ -11,7 +10,6 @@ from quart import (
|
||||
url_for,
|
||||
)
|
||||
from shared.browser.app.authz import require_admin, require_post_author
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
from shared.utils import host_url
|
||||
|
||||
@@ -24,6 +22,7 @@ def _post_to_edit_dict(post) -> dict:
|
||||
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",
|
||||
@@ -55,52 +54,6 @@ def _post_to_edit_dict(post) -> dict:
|
||||
def register():
|
||||
bp = Blueprint("admin", __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(slug: str):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# Load features for page admin (page_configs now lives in db_blog)
|
||||
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 ""
|
||||
|
||||
ctx = {
|
||||
"features": features,
|
||||
"sumup_configured": sumup_configured,
|
||||
"sumup_merchant_code": sumup_merchant_code,
|
||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_admin_page, render_post_admin_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(ctx)
|
||||
if not is_htmx_request():
|
||||
html = await render_post_admin_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_admin_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.put("/features/")
|
||||
@require_admin
|
||||
async def update_features(slug: str):
|
||||
@@ -184,79 +137,6 @@ def register():
|
||||
)
|
||||
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("/preview/")
|
||||
@require_admin
|
||||
async def preview(slug: str):
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_post_preview_page, render_post_preview_oob
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Build the 4 preview views
|
||||
preview_ctx = {}
|
||||
|
||||
# 1. Prettified sx source
|
||||
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)
|
||||
|
||||
# 2. Prettified lexical JSON
|
||||
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)
|
||||
|
||||
# 3. SX rendered preview
|
||||
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>"
|
||||
|
||||
# 4. Lexical rendered preview
|
||||
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>"
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx.update(preview_ctx)
|
||||
if not is_htmx_request():
|
||||
html = await render_post_preview_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_post_preview_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.get("/entries/calendar/<int:calendar_id>/")
|
||||
@require_admin
|
||||
async def calendar_view(slug: str, calendar_id: int):
|
||||
@@ -323,62 +203,14 @@ def register():
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(post_id)
|
||||
|
||||
html = await render_template(
|
||||
"_types/post/admin/_calendar_view.html",
|
||||
calendar=calendar_obj,
|
||||
year=year,
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
weekday_names=weekday_names,
|
||||
weeks=weeks,
|
||||
prev_month=prev_month,
|
||||
prev_month_year=prev_month_year,
|
||||
next_month=next_month,
|
||||
next_month_year=next_month_year,
|
||||
prev_year=prev_year,
|
||||
next_year=next_year,
|
||||
month_entries=month_entries,
|
||||
associated_entry_ids=associated_entry_ids,
|
||||
from sx.sx_components import render_calendar_view
|
||||
html = render_calendar_view(
|
||||
calendar_obj, year, month, month_name, weekday_names, weeks,
|
||||
prev_month, prev_month_year, next_month, next_month_year,
|
||||
prev_year, next_year, month_entries, associated_entry_ids,
|
||||
g.post_data["post"]["slug"],
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
@bp.get("/entries/")
|
||||
@require_admin
|
||||
async def entries(slug: str):
|
||||
from ..services.entry_associations import get_post_entry_ids
|
||||
from shared.models.calendars import Calendar
|
||||
from sqlalchemy import select
|
||||
|
||||
post_id = g.post_data["post"]["id"]
|
||||
associated_entry_ids = await get_post_entry_ids(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)
|
||||
return sx_response(html)
|
||||
|
||||
@bp.post("/entries/<int:entry_id>/toggle/")
|
||||
@require_admin
|
||||
@@ -432,40 +264,6 @@ def register():
|
||||
|
||||
return sx_response(admin_list + nav_entries_html)
|
||||
|
||||
@bp.get("/settings/")
|
||||
@require_post_author
|
||||
async def settings(slug: str):
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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"
|
||||
|
||||
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/")
|
||||
@require_post_author
|
||||
async def settings_save(slug: str):
|
||||
@@ -520,7 +318,7 @@ def register():
|
||||
except OptimisticLockError:
|
||||
from urllib.parse import quote
|
||||
return redirect(
|
||||
host_url(url_for("blog.post.admin.settings", slug=slug))
|
||||
host_url(url_for("defpage_post_settings", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
@@ -531,50 +329,7 @@ def register():
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect using the (possibly new) slug
|
||||
return redirect(host_url(url_for("blog.post.admin.settings", slug=post.slug)) + "?saved=1")
|
||||
|
||||
@bp.get("/edit/")
|
||||
@require_post_author
|
||||
async def edit(slug: str):
|
||||
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", "")
|
||||
|
||||
# Newsletters live in db_account — fetch via HTTP
|
||||
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 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)
|
||||
return redirect(host_url(url_for("defpage_post_settings", slug=post.slug)) + "?saved=1")
|
||||
|
||||
@bp.post("/edit/")
|
||||
@require_post_author
|
||||
@@ -599,11 +354,11 @@ def register():
|
||||
try:
|
||||
lexical_doc = json.loads(lexical_raw)
|
||||
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("defpage_post_edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
|
||||
|
||||
ok, reason = validate_lexical(lexical_doc)
|
||||
if not ok:
|
||||
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
|
||||
return redirect(host_url(url_for("defpage_post_edit", slug=slug)) + "?error=" + quote(reason))
|
||||
|
||||
# Publish workflow
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
@@ -620,6 +375,10 @@ def register():
|
||||
effective_status = status
|
||||
|
||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||
# Build optional kwargs — only pass sx_content if the form field was present
|
||||
extra_kw: dict = {}
|
||||
if "sx_content" in form:
|
||||
extra_kw["sx_content"] = sx_content_raw
|
||||
try:
|
||||
post = await writer_update(
|
||||
g.s,
|
||||
@@ -631,11 +390,11 @@ def register():
|
||||
custom_excerpt=custom_excerpt or None,
|
||||
feature_image_caption=feature_image_caption or None,
|
||||
status=effective_status,
|
||||
sx_content=sx_content_raw,
|
||||
**extra_kw,
|
||||
)
|
||||
except OptimisticLockError:
|
||||
return redirect(
|
||||
host_url(url_for("blog.post.admin.edit", slug=slug))
|
||||
host_url(url_for("defpage_post_edit", slug=slug))
|
||||
+ "?error=" + quote("Someone else edited this post. Please reload and try again.")
|
||||
)
|
||||
|
||||
@@ -651,7 +410,7 @@ def register():
|
||||
await invalidate_tag_cache("post.post_detail")
|
||||
|
||||
# Redirect to GET (PRG pattern) — use post.slug in case it changed
|
||||
redirect_url = host_url(url_for("blog.post.admin.edit", slug=post.slug)) + "?saved=1"
|
||||
redirect_url = host_url(url_for("defpage_post_edit", slug=post.slug)) + "?saved=1"
|
||||
if publish_requested_msg:
|
||||
redirect_url += "&publish_requested=1"
|
||||
return redirect(redirect_url)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, make_response, request, g, abort
|
||||
from quart import Blueprint, request, g, abort
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.browser.app.authz import require_login
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.helpers import sx_response
|
||||
from models import Snippet
|
||||
|
||||
@@ -32,26 +30,6 @@ async def _visible_snippets(session):
|
||||
def register():
|
||||
bp = Blueprint("snippets", __name__, url_prefix="/settings/snippets")
|
||||
|
||||
@bp.get("/")
|
||||
@require_login
|
||||
async def list_snippets():
|
||||
"""List snippets visible to the current user."""
|
||||
snippets = await _visible_snippets(g.s)
|
||||
is_admin = g.rights.get("admin")
|
||||
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import render_snippets_page, render_snippets_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = snippets
|
||||
tctx["is_admin"] = is_admin
|
||||
if not is_htmx_request():
|
||||
html = await render_snippets_page(tctx)
|
||||
return await make_response(html)
|
||||
else:
|
||||
sx_src = await render_snippets_oob(tctx)
|
||||
return sx_response(sx_src)
|
||||
|
||||
@bp.delete("/<int:snippet_id>/")
|
||||
@require_login
|
||||
async def delete_snippet(snippet_id: int):
|
||||
|
||||
@@ -55,6 +55,104 @@
|
||||
(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))))
|
||||
|
||||
;; Edit form — pre-populated version for /<slug>/admin/edit/
|
||||
(defcomp ~blog-editor-edit-form (&key csrf updated-at title-val excerpt-val
|
||||
feature-image feature-image-caption
|
||||
sx-content-val lexical-json
|
||||
has-sx title-placeholder
|
||||
status already-emailed
|
||||
newsletter-options footer-extra)
|
||||
(let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600")
|
||||
(active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent")
|
||||
(inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"))
|
||||
(form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "updated_at" :value updated-at)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val ""))
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image ""))
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption ""))
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
(div :id "feature-image-empty" :class (if feature-image "hidden" "")
|
||||
(button :type "button" :id "feature-image-add-btn"
|
||||
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||
"+ Add feature image"))
|
||||
(div :id "feature-image-filled" :class (str "relative " (if feature-image "" "hidden"))
|
||||
(img :id "feature-image-preview" :src (or feature-image "") :alt ""
|
||||
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
|
||||
(button :type "button" :id "feature-image-delete-btn"
|
||||
:class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
|
||||
:title "Remove feature image"
|
||||
(i :class "fa-solid fa-trash-can"))
|
||||
(input :type "text" :id "feature-image-caption" :value (or feature-image-caption "")
|
||||
:placeholder "Add a caption..."
|
||||
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
|
||||
(div :id "feature-image-uploading"
|
||||
:class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"
|
||||
(i :class "fa-solid fa-spinner fa-spin") " Uploading...")
|
||||
(input :type "file" :id "feature-image-file"
|
||||
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
|
||||
(input :type "text" :name "title" :value (or title-val "") :placeholder title-placeholder
|
||||
: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..."
|
||||
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
|
||||
(or excerpt-val ""))
|
||||
;; Editor tabs
|
||||
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
|
||||
(button :type "button" :id "editor-tab-sx"
|
||||
:class (if has-sx active inactive)
|
||||
: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 (if has-sx inactive active)
|
||||
: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"
|
||||
:style (if has-sx "" "display:none"))
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent"
|
||||
:style (if has-sx "display:none" ""))
|
||||
;; Initial lexical JSON
|
||||
(script :id "lexical-initial-data" :type "application/json" lexical-json)
|
||||
;; Footer: status + publish mode + newsletter + save + badges
|
||||
(div :class "flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||
(select :id "status-select" :name "status" :class sel-cls
|
||||
(option :value "draft" :selected (= status "draft") "Draft")
|
||||
(option :value "published" :selected (= status "published") "Published"))
|
||||
(select :id "publish-mode-select" :name "publish_mode"
|
||||
:class (str sel-cls (if (= status "published") "" " hidden")
|
||||
(if already-emailed " opacity-50 pointer-events-none" ""))
|
||||
:disabled (if already-emailed true nil)
|
||||
(option :value "web" :selected true "Web only")
|
||||
(option :value "email" "Email only")
|
||||
(option :value "both" "Web + Email"))
|
||||
(select :id "newsletter-select" :name "newsletter_slug"
|
||||
:class (str sel-cls " hidden")
|
||||
:disabled (if already-emailed true nil)
|
||||
newsletter-options)
|
||||
(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"
|
||||
"Save")
|
||||
(when footer-extra footer-extra)))))
|
||||
|
||||
;; Publish-mode show/hide script for edit form
|
||||
(defcomp ~blog-editor-publish-js (&key already-emailed)
|
||||
(script
|
||||
"(function() {"
|
||||
" var statusSel = document.getElementById('status-select');"
|
||||
" var modeSel = document.getElementById('publish-mode-select');"
|
||||
" var nlSel = document.getElementById('newsletter-select');"
|
||||
(str " var alreadyEmailed = " (if already-emailed "true" "false") ";")
|
||||
" function sync() {"
|
||||
" var isPublished = statusSel.value === 'published';"
|
||||
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
|
||||
" var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');"
|
||||
" if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }"
|
||||
" }"
|
||||
" statusSel.addEventListener('change', sync);"
|
||||
" modeSel.addEventListener('change', sync);"
|
||||
" sync();"
|
||||
"})();"))
|
||||
|
||||
(defcomp ~blog-editor-styles (&key css-href)
|
||||
(<> (link :rel "stylesheet" :href css-href)
|
||||
(style
|
||||
|
||||
@@ -26,6 +26,10 @@ from shared.sx.helpers import (
|
||||
search_mobile_sx,
|
||||
search_desktop_sx,
|
||||
full_page_sx,
|
||||
mobile_menu_sx,
|
||||
mobile_root_nav_sx,
|
||||
post_mobile_nav_sx,
|
||||
post_admin_mobile_nav_sx,
|
||||
)
|
||||
|
||||
# Load blog service .sx component definitions + handler definitions
|
||||
@@ -76,6 +80,15 @@ def _post_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -
|
||||
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
def _post_admin_mobile_menu(ctx: dict, selected: str = "") -> str:
|
||||
"""Full mobile menu for any post admin page (admin + post + root)."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return mobile_menu_sx(
|
||||
post_admin_mobile_nav_sx(ctx, slug, selected),
|
||||
post_mobile_nav_sx(ctx),
|
||||
mobile_root_nav_sx(ctx),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings header (root-header-child -> root-settings-header-child)
|
||||
@@ -85,7 +98,7 @@ def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Settings header row with admin icon and nav links (sx)."""
|
||||
from quart import url_for as qurl
|
||||
|
||||
settings_href = qurl("settings.home")
|
||||
settings_href = qurl("settings.defpage_settings_home")
|
||||
label_sx = sx_call("blog-admin-label")
|
||||
nav_sx = _settings_nav_sx(ctx)
|
||||
|
||||
@@ -107,10 +120,10 @@ def _settings_nav_sx(ctx: dict) -> str:
|
||||
parts = []
|
||||
|
||||
for endpoint, icon, label in [
|
||||
("menu_items.list_menu_items", "bars", "Menu Items"),
|
||||
("snippets.list_snippets", "puzzle-piece", "Snippets"),
|
||||
("blog.tag_groups_admin.index", "tags", "Tag Groups"),
|
||||
("settings.cache", "refresh", "Cache"),
|
||||
("menu_items.defpage_menu_items_page", "bars", "Menu Items"),
|
||||
("snippets.defpage_snippets_page", "puzzle-piece", "Snippets"),
|
||||
("blog.tag_groups_admin.defpage_tag_groups_page", "tags", "Tag Groups"),
|
||||
("settings.defpage_cache_page", "refresh", "Cache"),
|
||||
]:
|
||||
href = qurl(endpoint)
|
||||
parts.append(sx_call("nav-link",
|
||||
@@ -679,7 +692,7 @@ def _post_main_panel_sx(ctx: dict) -> str:
|
||||
if post.get("status") == "draft":
|
||||
edit_sx = ""
|
||||
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
|
||||
edit_href = qurl("blog.post.admin.edit", slug=slug)
|
||||
edit_href = qurl("blog.post.admin.defpage_post_edit", slug=slug)
|
||||
edit_sx = sx_call("blog-detail-edit-link",
|
||||
href=edit_href, hx_select=hx_select,
|
||||
)
|
||||
@@ -951,7 +964,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
|
||||
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
|
||||
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
|
||||
|
||||
edit_href = qurl("blog.tag_groups_admin.edit", id=g_id)
|
||||
edit_href = qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id)
|
||||
|
||||
if g_fi:
|
||||
icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name)
|
||||
@@ -1053,7 +1066,7 @@ async def render_home_page(ctx: dict) -> str:
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
content = _home_main_panel_sx(ctx)
|
||||
meta = _post_meta_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
|
||||
@@ -1088,9 +1101,8 @@ async def render_blog_oob(ctx: dict) -> str:
|
||||
content = _blog_main_panel_sx(ctx)
|
||||
aside = _blog_aside_sx(ctx)
|
||||
filter_sx = _blog_filter_sx(ctx)
|
||||
nav = ctx.get("nav_sx", "") or ""
|
||||
return oob_page_sx(oobs=header_oob, content=content, aside=aside,
|
||||
filter=filter_sx, menu=nav)
|
||||
filter=filter_sx)
|
||||
|
||||
|
||||
async def render_blog_cards(ctx: dict) -> str:
|
||||
@@ -1259,7 +1271,7 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
|
||||
"\n"
|
||||
" if (typeof SxEditor !== 'undefined') {\n"
|
||||
" SxEditor.mount('sx-editor', {\n"
|
||||
" initialSx: window.__SX_INITIAL__ || null,\n"
|
||||
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
|
||||
" csrfToken: csrfToken,\n"
|
||||
" uploadUrls: uploadUrls,\n"
|
||||
f" oembedUrl: '{oembed_url}',\n"
|
||||
@@ -1304,15 +1316,6 @@ async def render_new_post_page(ctx: dict) -> str:
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_new_post_oob(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
blog_hdr = _blog_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
|
||||
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
content = ctx.get("editor_html", "")
|
||||
return oob_page_sx(oobs=header_oob, content=content)
|
||||
|
||||
|
||||
# ---- Post detail ----
|
||||
|
||||
async def render_post_page(ctx: dict) -> str:
|
||||
@@ -1321,7 +1324,7 @@ async def render_post_page(ctx: dict) -> str:
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
content = _post_main_panel_sx(ctx)
|
||||
meta = _post_meta_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
meta=meta, menu=menu)
|
||||
|
||||
@@ -1332,52 +1335,132 @@ async def render_post_oob(ctx: dict) -> str:
|
||||
rows = "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
|
||||
content = _post_main_panel_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
menu = mobile_menu_sx(post_mobile_nav_sx(ctx), mobile_root_nav_sx(ctx))
|
||||
oobs = post_oob
|
||||
return oob_page_sx(oobs=oobs, content=content, menu=menu)
|
||||
|
||||
|
||||
# ---- Post admin ----
|
||||
|
||||
async def render_post_admin_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx)
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _post_admin_main_panel_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
menu=menu)
|
||||
# ===========================================================================
|
||||
|
||||
def _post_data_content_sx(ctx: dict) -> str:
|
||||
"""Build post data inspector panel natively (replaces _types/post_data/_main_panel.html)."""
|
||||
from markupsafe import escape as esc
|
||||
from quart import g
|
||||
|
||||
original_post = getattr(g, "post_data", {}).get("original_post")
|
||||
if original_post is None:
|
||||
return _raw_html_sx('<div class="px-4 py-8 text-stone-400">No post data available.</div>')
|
||||
|
||||
tablename = getattr(original_post, "__tablename__", "?")
|
||||
|
||||
def _render_scalar_table(obj):
|
||||
rows = []
|
||||
for col in obj.__mapper__.columns:
|
||||
key = col.key
|
||||
if key == "_sa_instance_state":
|
||||
continue
|
||||
val = getattr(obj, key, None)
|
||||
if val is None:
|
||||
val_html = '<span class="text-neutral-400">\u2014</span>'
|
||||
elif hasattr(val, "isoformat"):
|
||||
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(val.isoformat())}</code></pre>'
|
||||
elif isinstance(val, str):
|
||||
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs">{esc(val)}</pre>'
|
||||
else:
|
||||
val_html = f'<pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(str(val))}</code></pre>'
|
||||
rows.append(
|
||||
f'<tr class="border-t border-neutral-200 align-top">'
|
||||
f'<td class="px-3 py-2 whitespace-nowrap text-neutral-600 align-top">{esc(key)}</td>'
|
||||
f'<td class="px-3 py-2 align-top">{val_html}</td></tr>'
|
||||
)
|
||||
return (
|
||||
'<div class="w-full overflow-x-auto sm:overflow-visible">'
|
||||
'<table class="w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden">'
|
||||
'<thead class="bg-neutral-50/70"><tr>'
|
||||
'<th class="px-3 py-2 text-left font-medium w-40 sm:w-56">Field</th>'
|
||||
'<th class="px-3 py-2 text-left font-medium">Value</th>'
|
||||
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table></div>'
|
||||
)
|
||||
|
||||
def _render_model(obj, depth=0, max_depth=2):
|
||||
parts = [_render_scalar_table(obj)]
|
||||
rel_parts = []
|
||||
for rel in obj.__mapper__.relationships:
|
||||
rel_name = rel.key
|
||||
loaded = rel_name in obj.__dict__
|
||||
value = getattr(obj, rel_name, None) if loaded else None
|
||||
cardinality = "many" if rel.uselist else "one"
|
||||
cls_name = rel.mapper.class_.__name__
|
||||
loaded_label = "" if loaded else " \u2022 <em>not loaded</em>"
|
||||
|
||||
inner = ""
|
||||
if value is None:
|
||||
inner = '<span class="text-neutral-400">\u2014</span>'
|
||||
elif rel.uselist:
|
||||
items = list(value) if value else []
|
||||
inner = f'<div class="text-neutral-500 mb-2">{len(items)} item{"" if len(items) == 1 else "s"}</div>'
|
||||
if items and depth < max_depth:
|
||||
sub_rows = []
|
||||
for i, it in enumerate(items, 1):
|
||||
ident_parts = []
|
||||
for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
|
||||
if k in it.__mapper__.c:
|
||||
v = getattr(it, k, "")
|
||||
ident_parts.append(f"{k}={v}")
|
||||
summary = " \u2022 ".join(ident_parts) if ident_parts else str(it)
|
||||
child_html = ""
|
||||
if depth < max_depth:
|
||||
child_html = f'<div class="mt-2 pl-3 border-l border-neutral-200">{_render_model(it, depth + 1, max_depth)}</div>'
|
||||
else:
|
||||
child_html = '<div class="mt-1 text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
|
||||
sub_rows.append(
|
||||
f'<tr class="border-t border-neutral-200 align-top">'
|
||||
f'<td class="px-2 py-1 whitespace-nowrap align-top">{i}</td>'
|
||||
f'<td class="px-2 py-1 align-top"><pre class="whitespace-pre-wrap break-words break-all text-xs"><code>{esc(summary)}</code></pre>{child_html}</td></tr>'
|
||||
)
|
||||
inner += (
|
||||
'<div class="w-full overflow-x-auto sm:overflow-visible">'
|
||||
'<table class="w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden">'
|
||||
'<thead class="bg-neutral-50/70"><tr><th class="px-2 py-1 text-left w-10">#</th>'
|
||||
'<th class="px-2 py-1 text-left">Summary</th></tr></thead><tbody>'
|
||||
+ "".join(sub_rows) + '</tbody></table></div>'
|
||||
)
|
||||
else:
|
||||
child = value
|
||||
ident_parts = []
|
||||
for k in ("id", "ghost_id", "uuid", "slug", "name", "title"):
|
||||
if k in child.__mapper__.c:
|
||||
v = getattr(child, k, "")
|
||||
ident_parts.append(f"{k}={v}")
|
||||
summary = " \u2022 ".join(ident_parts) if ident_parts else str(child)
|
||||
inner = f'<pre class="whitespace-pre-wrap break-words break-all text-xs mb-2"><code>{esc(summary)}</code></pre>'
|
||||
if depth < max_depth:
|
||||
inner += f'<div class="pl-3 border-l border-neutral-200">{_render_model(child, depth + 1, max_depth)}</div>'
|
||||
else:
|
||||
inner += '<div class="text-xs text-neutral-500">\u2026max depth reached\u2026</div>'
|
||||
|
||||
rel_parts.append(
|
||||
f'<div class="rounded-xl border border-neutral-200">'
|
||||
f'<div class="px-3 py-2 bg-neutral-50/70 text-sm font-medium">'
|
||||
f'Relationship: <span class="font-semibold">{esc(rel_name)}</span>'
|
||||
f' <span class="ml-2 text-xs text-neutral-500">{cardinality} \u2192 {esc(cls_name)}{loaded_label}</span></div>'
|
||||
f'<div class="p-3 text-sm">{inner}</div></div>'
|
||||
)
|
||||
if rel_parts:
|
||||
parts.append('<div class="space-y-3">' + "".join(rel_parts) + '</div>')
|
||||
return '<div class="space-y-4">' + "".join(parts) + '</div>'
|
||||
|
||||
html = (
|
||||
f'<div class="px-4 py-8">'
|
||||
f'<div class="mb-6 text-sm text-neutral-500">Model: <code>Post</code> \u2022 Table: <code>{esc(tablename)}</code></div>'
|
||||
f'{_render_model(original_post, 0, 2)}</div>'
|
||||
)
|
||||
return _raw_html_sx(html)
|
||||
|
||||
|
||||
async def render_post_admin_oob(ctx: dict) -> str:
|
||||
post_hdr_oob = _post_header_sx(ctx, oob=True)
|
||||
admin_oob = _oob_header_sx("post-header-child", "post-admin-header-child",
|
||||
_post_admin_header_sx(ctx))
|
||||
content = _post_admin_main_panel_sx(ctx)
|
||||
menu = ctx.get("nav_sx", "") or ""
|
||||
oobs = "(<> " + post_hdr_oob + " " + admin_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content, menu=menu)
|
||||
|
||||
|
||||
# ---- Post data ----
|
||||
|
||||
async def render_post_data_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="data")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _raw_html_sx(ctx.get("data_html", ""))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_post_data_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="data")
|
||||
content = _raw_html_sx(ctx.get("data_html", ""))
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
|
||||
|
||||
# ---- Post preview ----
|
||||
# ===========================================================================
|
||||
|
||||
def _preview_main_panel_sx(ctx: dict) -> str:
|
||||
"""Build the preview panel with 4 expandable sections."""
|
||||
@@ -1424,36 +1507,165 @@ def _preview_main_panel_sx(ctx: dict) -> str:
|
||||
return sx_call("blog-preview-panel", sections=SxExpr(f"(<> {inner})"))
|
||||
|
||||
|
||||
async def render_post_preview_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="preview")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _preview_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
# ===========================================================================
|
||||
|
||||
def _post_entries_content_sx(ctx: dict) -> str:
|
||||
"""Build post entries panel natively (replaces _types/post_entries/_main_panel.html)."""
|
||||
from quart import g, url_for as qurl
|
||||
from shared.utils import host_url
|
||||
|
||||
all_calendars = ctx.get("all_calendars", [])
|
||||
associated_entry_ids = ctx.get("associated_entry_ids", set())
|
||||
post_slug = g.post_data["post"]["slug"]
|
||||
|
||||
# Associated entries list (reuse existing render function)
|
||||
assoc_html = render_associated_entries(all_calendars, associated_entry_ids, post_slug)
|
||||
|
||||
# Calendar browser
|
||||
cal_items: list[str] = []
|
||||
for cal in all_calendars:
|
||||
cal_post = getattr(cal, "post", None)
|
||||
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
|
||||
cal_title = escape(getattr(cal_post, "title", "")) if cal_post else ""
|
||||
cal_name = escape(getattr(cal, "name", ""))
|
||||
cal_view_url = host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal.id))
|
||||
|
||||
img_html = (
|
||||
f'<img src="{escape(cal_fi)}" alt="{cal_title}" class="w-12 h-12 rounded object-cover flex-shrink-0" />'
|
||||
if cal_fi else
|
||||
'<div class="w-12 h-12 rounded bg-stone-200 flex-shrink-0"></div>'
|
||||
)
|
||||
cal_items.append(
|
||||
f'<details class="border rounded-lg bg-white" data-toggle-group="calendar-browser">'
|
||||
f'<summary class="p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3">'
|
||||
f'{img_html}'
|
||||
f'<div class="flex-1">'
|
||||
f'<div class="font-semibold flex items-center gap-2"><i class="fa fa-calendar text-stone-500"></i> {cal_name}</div>'
|
||||
f'<div class="text-sm text-stone-600">{cal_title}</div>'
|
||||
f'</div></summary>'
|
||||
f'<div class="p-4 border-t" sx-get="{escape(cal_view_url)}" sx-trigger="intersect once" sx-swap="innerHTML">'
|
||||
f'<div class="text-sm text-stone-400">Loading calendar...</div>'
|
||||
f'</div></details>'
|
||||
)
|
||||
|
||||
if cal_items:
|
||||
browser_html = (
|
||||
'<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3>'
|
||||
+ "".join(cal_items) + '</div>'
|
||||
)
|
||||
else:
|
||||
browser_html = '<div class="space-y-3"><h3 class="text-lg font-semibold">Browse Calendars</h3><div class="text-sm text-stone-400">No calendars found.</div></div>'
|
||||
|
||||
# assoc_html is sx (from render_associated_entries); browser is raw HTML
|
||||
# Wrap the whole thing: open div as raw, then associated entries (sx), then browser (raw), close div
|
||||
return (
|
||||
_raw_html_sx('<div id="post-entries-content" class="space-y-6 p-4">')
|
||||
+ assoc_html
|
||||
+ _raw_html_sx(browser_html + '</div>')
|
||||
)
|
||||
|
||||
|
||||
async def render_post_preview_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="preview")
|
||||
content = _preview_main_panel_sx(ctx)
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
# ---- Calendar view (for entries browser) ----
|
||||
|
||||
def render_calendar_view(
|
||||
calendar, year, month, month_name, weekday_names, weeks,
|
||||
prev_month, prev_month_year, next_month, next_month_year,
|
||||
prev_year, next_year, month_entries, associated_entry_ids,
|
||||
post_slug: str,
|
||||
) -> str:
|
||||
"""Build calendar month grid HTML (replaces _types/post/admin/_calendar_view.html)."""
|
||||
from quart import url_for as qurl
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.utils import host_url
|
||||
esc = escape
|
||||
|
||||
# ---- Post entries ----
|
||||
csrf = generate_csrf_token()
|
||||
cal_id = calendar.id
|
||||
|
||||
async def render_post_entries_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="entries")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _raw_html_sx(ctx.get("entries_html", ""))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
def cal_url(y, m):
|
||||
return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
|
||||
|
||||
cur_url = cal_url(year, month)
|
||||
toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
|
||||
|
||||
async def render_post_entries_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="entries")
|
||||
content = _raw_html_sx(ctx.get("entries_html", ""))
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
# Navigation header
|
||||
nav = (
|
||||
f'<header class="flex items-center justify-center mb-4">'
|
||||
f'<nav class="flex items-center gap-2 text-xl">'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">«</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(prev_month_year, prev_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">‹</a>'
|
||||
f'<div class="px-3 font-medium">{esc(month_name)} {year}</div>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_month_year, next_month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">›</a>'
|
||||
f'<a class="px-2 py-1 hover:bg-stone-100 rounded" sx-get="{cal_url(next_year, month)}" sx-target="#calendar-view-{cal_id}" sx-swap="outerHTML">»</a>'
|
||||
f'</nav></header>'
|
||||
)
|
||||
|
||||
# Weekday header
|
||||
wd_cells = "".join(f'<div class="py-2">{esc(wd)}</div>' for wd in weekday_names)
|
||||
wd_row = f'<div class="hidden sm:grid grid-cols-7 text-center text-xs font-semibold text-stone-700 bg-stone-50 border-b">{wd_cells}</div>'
|
||||
|
||||
# Grid cells
|
||||
cells: list[str] = []
|
||||
for week in weeks:
|
||||
for day in week:
|
||||
extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
|
||||
day_date = day.date
|
||||
|
||||
entry_btns: list[str] = []
|
||||
for e in month_entries:
|
||||
e_start = getattr(e, "start_at", None)
|
||||
if not e_start or e_start.date() != day_date:
|
||||
continue
|
||||
e_id = getattr(e, "id", None)
|
||||
e_name = esc(getattr(e, "name", ""))
|
||||
t_url = toggle_url_fn(e_id)
|
||||
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
|
||||
|
||||
if e_id in associated_entry_ids:
|
||||
entry_btns.append(
|
||||
f'<div class="flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900">'
|
||||
f'<span class="truncate flex-1">{e_name}</span>'
|
||||
f'<button type="button" class="flex-shrink-0 hover:text-red-600"'
|
||||
f' data-confirm data-confirm-title="Remove entry?"'
|
||||
f' data-confirm-text="Remove {e_name} from this post?"'
|
||||
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><i class="fa fa-times"></i></button></div>'
|
||||
)
|
||||
else:
|
||||
entry_btns.append(
|
||||
f'<button type="button" class="w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"'
|
||||
f' data-confirm data-confirm-title="Add entry?"'
|
||||
f' data-confirm-text="Add {e_name} to this post?"'
|
||||
f' data-confirm-icon="question" data-confirm-confirm-text="Yes, add it"'
|
||||
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
|
||||
f' sx-post="{t_url}" sx-trigger="confirmed"'
|
||||
f' sx-target="#associated-entries-list" sx-swap="outerHTML"'
|
||||
f""" sx-headers='{hx_hdrs}'"""
|
||||
f' sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent(\'entryToggled\'))"'
|
||||
f'><span class="truncate block">{e_name}</span></button>'
|
||||
)
|
||||
|
||||
entries_html = '<div class="space-y-0.5">' + "".join(entry_btns) + '</div>' if entry_btns else ''
|
||||
cells.append(
|
||||
f'<div class="min-h-20 bg-white px-2 py-2 text-xs{extra_cls}">'
|
||||
f'<div class="font-medium mb-1">{day_date.day}</div>{entries_html}</div>'
|
||||
)
|
||||
|
||||
grid = f'<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200">{"".join(cells)}</div>'
|
||||
|
||||
html = (
|
||||
f'<div id="calendar-view-{cal_id}"'
|
||||
f' sx-get="{cur_url}" sx-trigger="entryToggled from:body" sx-swap="outerHTML">'
|
||||
f'{nav}'
|
||||
f'<div class="rounded border bg-white">{wd_row}{grid}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
return _raw_html_sx(html)
|
||||
|
||||
|
||||
# ---- Post edit ----
|
||||
@@ -1465,206 +1677,361 @@ def _raw_html_sx(html: str) -> str:
|
||||
return "(raw! " + sx_serialize(html) + ")"
|
||||
|
||||
|
||||
async def render_post_edit_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="edit")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _raw_html_sx(ctx.get("edit_html", ""))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
def _post_edit_content_sx(ctx: dict) -> str:
|
||||
"""Build WYSIWYG editor panel as SX expression (edit page)."""
|
||||
from quart import url_for as qurl, current_app, g, request as qrequest
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
ghost_post = ctx.get("ghost_post", {}) or {}
|
||||
save_success = ctx.get("save_success", False)
|
||||
save_error = ctx.get("save_error", "")
|
||||
newsletters = ctx.get("newsletters", [])
|
||||
|
||||
async def render_post_edit_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="edit")
|
||||
content = _raw_html_sx(ctx.get("edit_html", ""))
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
csrf = generate_csrf_token()
|
||||
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
|
||||
editor_css = asset_url_fn("scripts/editor.css")
|
||||
editor_js = asset_url_fn("scripts/editor.js")
|
||||
sx_editor_js = asset_url_fn("scripts/sx-editor.js")
|
||||
|
||||
upload_image_url = qurl("blog.editor_api.upload_image")
|
||||
upload_media_url = qurl("blog.editor_api.upload_media")
|
||||
upload_file_url = qurl("blog.editor_api.upload_file")
|
||||
oembed_url = qurl("blog.editor_api.oembed_proxy")
|
||||
snippets_url = qurl("blog.editor_api.list_snippets")
|
||||
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")
|
||||
|
||||
# ---- Post settings ----
|
||||
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
|
||||
is_page = post.get("is_page", False)
|
||||
|
||||
async def render_post_settings_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = _post_header_sx(ctx)
|
||||
admin_hdr = _post_admin_header_sx(ctx, selected="settings")
|
||||
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
content = _raw_html_sx(ctx.get("settings_html", ""))
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
feature_image = ghost_post.get("feature_image") or ""
|
||||
feature_image_caption = ghost_post.get("feature_image_caption") or ""
|
||||
title_val = ghost_post.get("title") or ""
|
||||
excerpt_val = ghost_post.get("custom_excerpt") or ""
|
||||
updated_at = ghost_post.get("updated_at") or ""
|
||||
status = ghost_post.get("status") or "draft"
|
||||
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
|
||||
sx_content = ghost_post.get("sx_content") or ""
|
||||
has_sx = bool(sx_content)
|
||||
|
||||
already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
|
||||
email_obj = ghost_post.get("email")
|
||||
if email_obj and not isinstance(email_obj, dict):
|
||||
already_emailed = bool(getattr(email_obj, "status", None))
|
||||
|
||||
async def render_post_settings_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="settings")
|
||||
content = _raw_html_sx(ctx.get("settings_html", ""))
|
||||
return oob_page_sx(oobs=admin_hdr_oob, content=content)
|
||||
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||
|
||||
# Newsletter options as SX fragment
|
||||
nl_parts = ['(option :value "" "Select newsletter\u2026")']
|
||||
for nl in newsletters:
|
||||
nl_slug = sx_serialize(getattr(nl, "slug", ""))
|
||||
nl_name = sx_serialize(getattr(nl, "name", ""))
|
||||
nl_parts.append(f"(option :value {nl_slug} {nl_name})")
|
||||
nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")")
|
||||
|
||||
# ---- Settings home ----
|
||||
# Footer extra badges as SX fragment
|
||||
badge_parts: list[str] = []
|
||||
if save_success:
|
||||
badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")')
|
||||
publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
|
||||
if publish_requested:
|
||||
badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")')
|
||||
if post.get("publish_requested"):
|
||||
badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")')
|
||||
if already_emailed:
|
||||
nl_name = ""
|
||||
newsletter = ghost_post.get("newsletter")
|
||||
if newsletter:
|
||||
nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "")
|
||||
suffix = f" to {nl_name}" if nl_name else ""
|
||||
badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")')
|
||||
footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None
|
||||
|
||||
async def render_settings_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
content = _settings_main_panel_sx(ctx)
|
||||
menu = _settings_nav_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content,
|
||||
menu=menu)
|
||||
parts: list[str] = []
|
||||
|
||||
# Error banner
|
||||
if save_error:
|
||||
parts.append(sx_call("blog-editor-error", error=save_error))
|
||||
|
||||
async def render_settings_oob(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
|
||||
header_oob = _oob_header_sx("root-header-child", "root-settings-header-child", rows)
|
||||
content = _settings_main_panel_sx(ctx)
|
||||
menu = _settings_nav_sx(ctx)
|
||||
return oob_page_sx(oobs=header_oob, content=content, menu=menu)
|
||||
# Form (sx_content_val populates #sx-content-input; JS reads from there)
|
||||
parts.append(sx_call("blog-editor-edit-form",
|
||||
csrf=csrf,
|
||||
updated_at=str(updated_at),
|
||||
title_val=title_val,
|
||||
excerpt_val=excerpt_val,
|
||||
feature_image=feature_image,
|
||||
feature_image_caption=feature_image_caption,
|
||||
sx_content_val=sx_content,
|
||||
lexical_json=lexical_json,
|
||||
has_sx=has_sx,
|
||||
title_placeholder=title_placeholder,
|
||||
status=status,
|
||||
already_emailed=already_emailed,
|
||||
newsletter_options=nl_opts_sx,
|
||||
footer_extra=footer_extra_sx,
|
||||
))
|
||||
|
||||
# Publish-mode JS
|
||||
parts.append(sx_call("blog-editor-publish-js", already_emailed=already_emailed))
|
||||
|
||||
# ---- Cache ----
|
||||
# Editor CSS + styles
|
||||
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
|
||||
parts.append(sx_call("sx-editor-styles"))
|
||||
|
||||
async def render_cache_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
cache_hdr = _sub_settings_header_sx(
|
||||
"cache-row", "cache-header-child",
|
||||
qurl("settings.cache"), "refresh", "Cache", ctx,
|
||||
# Editor JS + init
|
||||
init_js = (
|
||||
'(function() {'
|
||||
" function applyEditorFontSize() {"
|
||||
" document.documentElement.style.fontSize = '62.5%';"
|
||||
" document.body.style.fontSize = '1.6rem';"
|
||||
' }'
|
||||
" function restoreDefaultFontSize() {"
|
||||
" document.documentElement.style.fontSize = '';"
|
||||
" document.body.style.fontSize = '';"
|
||||
' }'
|
||||
' applyEditorFontSize();'
|
||||
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {"
|
||||
" if (e.detail.target && e.detail.target.id === 'main-panel') {"
|
||||
' restoreDefaultFontSize();'
|
||||
" document.body.removeEventListener('htmx:beforeSwap', cleanup);"
|
||||
' }'
|
||||
' });'
|
||||
' function init() {'
|
||||
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;"
|
||||
f" var uploadUrl = '{upload_image_url}';"
|
||||
' var uploadUrls = {'
|
||||
' image: uploadUrl,'
|
||||
f" media: '{upload_media_url}',"
|
||||
f" file: '{upload_file_url}',"
|
||||
' };'
|
||||
" var fileInput = document.getElementById('feature-image-file');"
|
||||
" var addBtn = document.getElementById('feature-image-add-btn');"
|
||||
" var deleteBtn = document.getElementById('feature-image-delete-btn');"
|
||||
" var preview = document.getElementById('feature-image-preview');"
|
||||
" var emptyState = document.getElementById('feature-image-empty');"
|
||||
" var filledState = document.getElementById('feature-image-filled');"
|
||||
" var hiddenUrl = document.getElementById('feature-image-input');"
|
||||
" var hiddenCaption = document.getElementById('feature-image-caption-input');"
|
||||
" var captionInput = document.getElementById('feature-image-caption');"
|
||||
" var uploading = document.getElementById('feature-image-uploading');"
|
||||
' function showFilled(url) {'
|
||||
' preview.src = url; hiddenUrl.value = url;'
|
||||
" emptyState.classList.add('hidden'); filledState.classList.remove('hidden'); uploading.classList.add('hidden');"
|
||||
' }'
|
||||
' function showEmpty() {'
|
||||
" preview.src = ''; hiddenUrl.value = ''; hiddenCaption.value = ''; captionInput.value = '';"
|
||||
" emptyState.classList.remove('hidden'); filledState.classList.add('hidden'); uploading.classList.add('hidden');"
|
||||
' }'
|
||||
' function uploadFile(file) {'
|
||||
" emptyState.classList.add('hidden'); uploading.classList.remove('hidden');"
|
||||
" var fd = new FormData(); fd.append('file', file);"
|
||||
" fetch(uploadUrl, { method: 'POST', body: fd, headers: { 'X-CSRFToken': csrfToken } })"
|
||||
" .then(function(r) { if (!r.ok) throw new Error('Upload failed (' + r.status + ')'); return r.json(); })"
|
||||
' .then(function(data) {'
|
||||
' var url = data.images && data.images[0] && data.images[0].url;'
|
||||
" if (url) showFilled(url); else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }"
|
||||
' })'
|
||||
' .catch(function(e) { showEmpty(); alert(e.message); });'
|
||||
' }'
|
||||
" addBtn.addEventListener('click', function() { fileInput.click(); });"
|
||||
" preview.addEventListener('click', function() { fileInput.click(); });"
|
||||
" deleteBtn.addEventListener('click', function(e) { e.stopPropagation(); showEmpty(); });"
|
||||
" fileInput.addEventListener('change', function() {"
|
||||
' if (fileInput.files && fileInput.files[0]) { uploadFile(fileInput.files[0]); fileInput.value = \'\'; }'
|
||||
' });'
|
||||
" captionInput.addEventListener('input', function() { hiddenCaption.value = captionInput.value; });"
|
||||
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');"
|
||||
" function autoResize() { excerpt.style.height = 'auto'; excerpt.style.height = excerpt.scrollHeight + 'px'; }"
|
||||
" excerpt.addEventListener('input', autoResize); autoResize();"
|
||||
' var dataEl = document.getElementById(\'lexical-initial-data\');'
|
||||
' var initialJson = dataEl ? dataEl.textContent.trim() : null;'
|
||||
' if (initialJson) { var hidden = document.getElementById(\'lexical-json-input\'); if (hidden) hidden.value = initialJson; }'
|
||||
" window.mountEditor('lexical-editor', {"
|
||||
' initialJson: initialJson,'
|
||||
' csrfToken: csrfToken,'
|
||||
' uploadUrls: uploadUrls,'
|
||||
f" oembedUrl: '{oembed_url}',"
|
||||
f" unsplashApiKey: '{unsplash_key}',"
|
||||
f" snippetsUrl: '{snippets_url}',"
|
||||
' });'
|
||||
" if (typeof SxEditor !== 'undefined') {"
|
||||
" SxEditor.mount('sx-editor', {"
|
||||
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
|
||||
' csrfToken: csrfToken,'
|
||||
' uploadUrls: uploadUrls,'
|
||||
f" oembedUrl: '{oembed_url}',"
|
||||
' onChange: function(sx) {'
|
||||
" document.getElementById('sx-content-input').value = sx;"
|
||||
' }'
|
||||
' });'
|
||||
' }'
|
||||
" document.addEventListener('keydown', function(e) {"
|
||||
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
|
||||
" e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();"
|
||||
' }'
|
||||
' });'
|
||||
' }'
|
||||
" if (typeof window.mountEditor === 'function') { init(); }"
|
||||
' else { var _t = setInterval(function() {'
|
||||
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
|
||||
' }, 50); }'
|
||||
'})();'
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + cache_hdr + ")"
|
||||
content = _cache_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
parts.append(sx_call("blog-editor-scripts",
|
||||
js_src=editor_js,
|
||||
sx_editor_js_src=sx_editor_js,
|
||||
init_js=init_js))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
async def render_cache_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
cache_hdr = _sub_settings_header_sx(
|
||||
"cache-row", "cache-header-child",
|
||||
qurl("settings.cache"), "refresh", "Cache", ctx,
|
||||
# ===========================================================================
|
||||
|
||||
def _post_settings_content_sx(ctx: dict) -> str:
|
||||
"""Build settings form natively (replaces _types/post_settings/_main_panel.html)."""
|
||||
from quart import g
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
esc = escape
|
||||
|
||||
ghost_post = ctx.get("ghost_post", {}) or {}
|
||||
save_success = ctx.get("save_success", False)
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
post = g.post_data.get("post", {}) if hasattr(g, "post_data") else {}
|
||||
is_page = post.get("is_page", False)
|
||||
|
||||
def field_label(text, field_for=None):
|
||||
for_attr = f' for="{field_for}"' if field_for else ''
|
||||
return f'<label{for_attr} class="block text-[13px] font-medium text-stone-500 mb-[4px]">{esc(text)}</label>'
|
||||
|
||||
input_cls = ('w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] '
|
||||
'bg-white text-stone-700 placeholder:text-stone-300 '
|
||||
'focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300')
|
||||
textarea_cls = input_cls + ' resize-y'
|
||||
|
||||
def text_input(name, value='', placeholder='', input_type='text', maxlength=None):
|
||||
ml = f' maxlength="{maxlength}"' if maxlength else ''
|
||||
return (f'<input type="{input_type}" name="{name}" id="settings-{name}" value="{esc(value)}"'
|
||||
f' placeholder="{esc(placeholder)}"{ml} class="{input_cls}">')
|
||||
|
||||
def textarea_input(name, value='', placeholder='', rows=3, maxlength=None):
|
||||
ml = f' maxlength="{maxlength}"' if maxlength else ''
|
||||
return (f'<textarea name="{name}" id="settings-{name}" rows="{rows}"'
|
||||
f' placeholder="{esc(placeholder)}"{ml} class="{textarea_cls}">{esc(value)}</textarea>')
|
||||
|
||||
def checkbox_input(name, checked=False, label=''):
|
||||
chk = ' checked' if checked else ''
|
||||
return (f'<label class="inline-flex items-center gap-[8px] cursor-pointer">'
|
||||
f'<input type="checkbox" name="{name}" id="settings-{name}"{chk}'
|
||||
f' class="rounded border-stone-300 text-stone-600 focus:ring-stone-300">'
|
||||
f'<span class="text-[14px] text-stone-600">{esc(label)}</span></label>')
|
||||
|
||||
def section(title, content, is_open=False):
|
||||
open_attr = ' open' if is_open else ''
|
||||
return (f'<details class="border border-stone-200 rounded-[8px] overflow-hidden"{open_attr}>'
|
||||
f'<summary class="px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors">{esc(title)}</summary>'
|
||||
f'<div class="px-[16px] py-[12px] space-y-[12px]">{content}</div></details>')
|
||||
|
||||
gp = ghost_post
|
||||
|
||||
# General section
|
||||
slug_placeholder = 'page-slug' if is_page else 'post-slug'
|
||||
pub_at = gp.get("published_at") or ""
|
||||
pub_at_val = pub_at[:16] if pub_at else ""
|
||||
vis = gp.get("visibility") or "public"
|
||||
vis_opts = "".join(
|
||||
f'<option value="{v}"{" selected" if vis == v else ""}>{l}</option>'
|
||||
for v, l in [("public", "Public"), ("members", "Members"), ("paid", "Paid")]
|
||||
)
|
||||
cache_oob = _oob_header_sx("root-settings-header-child", "cache-header-child",
|
||||
cache_hdr)
|
||||
content = _cache_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + cache_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
# ---- Snippets ----
|
||||
|
||||
async def render_snippets_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
snippets_hdr = _sub_settings_header_sx(
|
||||
"snippets-row", "snippets-header-child",
|
||||
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
|
||||
general = (
|
||||
f'<div>{field_label("Slug", "settings-slug")}{text_input("slug", gp.get("slug") or "", slug_placeholder)}</div>'
|
||||
f'<div>{field_label("Published at", "settings-published_at")}'
|
||||
f'<input type="datetime-local" name="published_at" id="settings-published_at" value="{esc(pub_at_val)}" class="{input_cls}"></div>'
|
||||
f'<div>{checkbox_input("featured", gp.get("featured"), "Featured page" if is_page else "Featured post")}</div>'
|
||||
f'<div>{field_label("Visibility", "settings-visibility")}'
|
||||
f'<select name="visibility" id="settings-visibility" class="{input_cls}">{vis_opts}</select></div>'
|
||||
f'<div>{checkbox_input("email_only", gp.get("email_only"), "Email only")}</div>'
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + snippets_hdr + ")"
|
||||
content = _snippets_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_snippets_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
snippets_hdr = _sub_settings_header_sx(
|
||||
"snippets-row", "snippets-header-child",
|
||||
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
|
||||
# Tags
|
||||
tags = gp.get("tags") or []
|
||||
if tags:
|
||||
tag_names = ", ".join(getattr(t, "name", t.get("name", "") if isinstance(t, dict) else str(t)) for t in tags)
|
||||
else:
|
||||
tag_names = ""
|
||||
tags_sec = (
|
||||
f'<div>{field_label("Tags (comma-separated)", "settings-tags")}'
|
||||
f'{text_input("tags", tag_names, "news, updates, featured")}'
|
||||
f'<p class="text-[12px] text-stone-400 mt-[4px]">Unknown tags will be created automatically.</p></div>'
|
||||
)
|
||||
snippets_oob = _oob_header_sx("root-settings-header-child", "snippets-header-child",
|
||||
snippets_hdr)
|
||||
content = _snippets_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + snippets_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
# Feature image
|
||||
fi_sec = f'<div>{field_label("Alt text", "settings-feature_image_alt")}{text_input("feature_image_alt", gp.get("feature_image_alt") or "", "Describe the feature image")}</div>'
|
||||
|
||||
# ---- Menu items ----
|
||||
|
||||
async def render_menu_items_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
mi_hdr = _sub_settings_header_sx(
|
||||
"menu_items-row", "menu_items-header-child",
|
||||
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
|
||||
# SEO
|
||||
seo_sec = (
|
||||
f'<div>{field_label("Meta title", "settings-meta_title")}{text_input("meta_title", gp.get("meta_title") or "", "SEO title", maxlength=300)}'
|
||||
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 70 characters. Max: 300.</p></div>'
|
||||
f'<div>{field_label("Meta description", "settings-meta_description")}{textarea_input("meta_description", gp.get("meta_description") or "", "SEO description", rows=2, maxlength=500)}'
|
||||
f'<p class="text-[12px] text-stone-400 mt-[2px]">Recommended: 156 characters.</p></div>'
|
||||
f'<div>{field_label("Canonical URL", "settings-canonical_url")}{text_input("canonical_url", gp.get("canonical_url") or "", "https://example.com/original-post", input_type="url")}</div>'
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + mi_hdr + ")"
|
||||
content = _menu_items_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
async def render_menu_items_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
mi_hdr = _sub_settings_header_sx(
|
||||
"menu_items-row", "menu_items-header-child",
|
||||
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
|
||||
# Facebook / OG
|
||||
og_sec = (
|
||||
f'<div>{field_label("OG title", "settings-og_title")}{text_input("og_title", gp.get("og_title") or "")}</div>'
|
||||
f'<div>{field_label("OG description", "settings-og_description")}{textarea_input("og_description", gp.get("og_description") or "", rows=2)}</div>'
|
||||
f'<div>{field_label("OG image URL", "settings-og_image")}{text_input("og_image", gp.get("og_image") or "", "https://...", input_type="url")}</div>'
|
||||
)
|
||||
mi_oob = _oob_header_sx("root-settings-header-child", "menu_items-header-child",
|
||||
mi_hdr)
|
||||
content = _menu_items_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + mi_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
|
||||
# ---- Tag groups ----
|
||||
|
||||
async def render_tag_groups_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
|
||||
# Twitter
|
||||
tw_sec = (
|
||||
f'<div>{field_label("Twitter title", "settings-twitter_title")}{text_input("twitter_title", gp.get("twitter_title") or "")}</div>'
|
||||
f'<div>{field_label("Twitter description", "settings-twitter_description")}{textarea_input("twitter_description", gp.get("twitter_description") or "", rows=2)}</div>'
|
||||
f'<div>{field_label("Twitter image URL", "settings-twitter_image")}{text_input("twitter_image", gp.get("twitter_image") or "", "https://...", input_type="url")}</div>'
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
|
||||
content = _tag_groups_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
|
||||
# Advanced
|
||||
tmpl_placeholder = 'custom-page.hbs' if is_page else 'custom-post.hbs'
|
||||
adv_sec = f'<div>{field_label("Custom template", "settings-custom_template")}{text_input("custom_template", gp.get("custom_template") or "", tmpl_placeholder)}</div>'
|
||||
|
||||
async def render_tag_groups_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
|
||||
sections = (
|
||||
section("General", general, is_open=True)
|
||||
+ section("Tags", tags_sec)
|
||||
+ section("Feature Image", fi_sec)
|
||||
+ section("SEO / Meta", seo_sec)
|
||||
+ section("Facebook / OpenGraph", og_sec)
|
||||
+ section("X / Twitter", tw_sec)
|
||||
+ section("Advanced", adv_sec)
|
||||
)
|
||||
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
|
||||
tg_hdr)
|
||||
content = _tag_groups_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
|
||||
saved_html = '<span class="text-[14px] text-green-600">Saved.</span>' if save_success else ''
|
||||
|
||||
# ---- Tag group edit ----
|
||||
|
||||
async def render_tag_group_edit_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
settings_hdr = _settings_header_sx(ctx)
|
||||
from quart import url_for as qurl
|
||||
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
|
||||
html = (
|
||||
f'<form method="post" class="max-w-[640px] mx-auto pb-[48px] px-[16px]">'
|
||||
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||
f'<input type="hidden" name="updated_at" value="{esc(gp.get("updated_at") or "")}">'
|
||||
f'<div class="space-y-[12px] mt-[16px]">{sections}</div>'
|
||||
f'<div class="flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200">'
|
||||
f'<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">Save settings</button>'
|
||||
f'{saved_html}</div></form>'
|
||||
)
|
||||
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
|
||||
content = _tag_groups_edit_main_panel_sx(ctx)
|
||||
return full_page_sx(ctx, header_rows=header_rows, content=content)
|
||||
return _raw_html_sx(html)
|
||||
|
||||
|
||||
async def render_tag_group_edit_oob(ctx: dict) -> str:
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
|
||||
tg_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
|
||||
)
|
||||
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
|
||||
tg_hdr)
|
||||
content = _tag_groups_edit_main_panel_sx(ctx)
|
||||
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
|
||||
return oob_page_sx(oobs=oobs, content=content)
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
# ===========================================================================
|
||||
# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers
|
||||
|
||||
574
blog/sxc/pages/__init__.py
Normal file
574
blog/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,574 @@
|
||||
"""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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared hydration helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_to_defpage_ctx(**kwargs: Any) -> None:
|
||||
from quart import g
|
||||
if not hasattr(g, '_defpage_ctx'):
|
||||
g._defpage_ctx = {}
|
||||
g._defpage_ctx.update(kwargs)
|
||||
|
||||
|
||||
async def _ensure_post_data(slug: str | None) -> None:
|
||||
"""Load post data and set g.post_data + defpage context.
|
||||
|
||||
Replicates post bp's hydrate_post_data + context_processor.
|
||||
"""
|
||||
from quart import g, abort
|
||||
|
||||
if hasattr(g, 'post_data') and g.post_data:
|
||||
await _inject_post_context(g.post_data)
|
||||
return
|
||||
|
||||
if not slug:
|
||||
abort(404)
|
||||
|
||||
from bp.post.services.post_data import post_data
|
||||
|
||||
is_admin = bool((g.get("rights") or {}).get("admin"))
|
||||
p_data = await post_data(slug, g.s, include_drafts=True)
|
||||
if not p_data:
|
||||
abort(404)
|
||||
|
||||
# Draft access control
|
||||
if p_data["post"].get("status") != "published":
|
||||
if is_admin:
|
||||
pass
|
||||
elif g.user and p_data["post"].get("user_id") == g.user.id:
|
||||
pass
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
g.post_data = p_data
|
||||
g.post_slug = slug
|
||||
await _inject_post_context(p_data)
|
||||
|
||||
|
||||
async def _inject_post_context(p_data: dict) -> None:
|
||||
"""Add post context_processor data to defpage context."""
|
||||
from shared.config import config
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
|
||||
db_post_id = p_data["post"]["id"]
|
||||
post_slug = p_data["post"]["slug"]
|
||||
|
||||
container_nav = await fetch_fragment("relations", "container-nav", params={
|
||||
"container_type": "page",
|
||||
"container_id": str(db_post_id),
|
||||
"post_slug": post_slug,
|
||||
})
|
||||
|
||||
ctx: dict = {
|
||||
**p_data,
|
||||
"base_title": config()["title"],
|
||||
"container_nav": container_nav,
|
||||
}
|
||||
|
||||
if p_data["post"].get("is_page"):
|
||||
ident = current_cart_identity()
|
||||
summary_params: dict = {"page_slug": post_slug}
|
||||
if ident["user_id"] is not None:
|
||||
summary_params["user_id"] = ident["user_id"]
|
||||
if ident["session_id"] is not None:
|
||||
summary_params["session_id"] = ident["session_id"]
|
||||
raw_summary = await fetch_data(
|
||||
"cart", "cart-summary", params=summary_params, required=False,
|
||||
)
|
||||
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
|
||||
ctx["page_cart_count"] = (
|
||||
page_summary.count + page_summary.calendar_count + page_summary.ticket_count
|
||||
)
|
||||
ctx["page_cart_total"] = float(
|
||||
page_summary.total + page_summary.calendar_total + page_summary.ticket_total
|
||||
)
|
||||
|
||||
_add_to_defpage_ctx(**ctx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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",
|
||||
"defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
def _cache_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "cache-row", "cache-header-child",
|
||||
"defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
# --- Snippets ---
|
||||
|
||||
def _snippets_full(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
|
||||
"defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||
|
||||
|
||||
def _snippets_oob(ctx: dict, **kw: Any) -> str:
|
||||
return _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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("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("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 (async 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,
|
||||
})
|
||||
|
||||
|
||||
# --- Editor helpers ---
|
||||
|
||||
async def _h_editor_content(**kw):
|
||||
from sx.sx_components import render_editor_panel
|
||||
return render_editor_panel()
|
||||
|
||||
|
||||
async def _h_editor_page_content(**kw):
|
||||
from sx.sx_components import render_editor_panel
|
||||
return render_editor_panel(is_page=True)
|
||||
|
||||
|
||||
# --- Post admin helpers ---
|
||||
|
||||
async def _h_post_admin_content(slug=None, **kw):
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
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,
|
||||
})
|
||||
return _post_admin_main_panel_sx(tctx)
|
||||
|
||||
|
||||
async def _h_post_data_content(slug=None, **kw):
|
||||
await _ensure_post_data(slug)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _post_data_content_sx
|
||||
tctx = await get_template_context()
|
||||
return _post_data_content_sx(tctx)
|
||||
|
||||
|
||||
async def _h_post_preview_content(slug=None, **kw):
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
post_id = g.post_data["post"]["id"]
|
||||
post = (await g.s.execute(
|
||||
sa_select(Post).where(Post.id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
preview_ctx: dict = {}
|
||||
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)
|
||||
return _preview_main_panel_sx(tctx)
|
||||
|
||||
|
||||
async def _h_post_entries_content(slug=None, **kw):
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from shared.models.calendars import Calendar
|
||||
from bp.post.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
|
||||
return _post_entries_content_sx(tctx)
|
||||
|
||||
|
||||
async def _h_post_settings_content(slug=None, **kw):
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g, request
|
||||
from models.ghost_content import Post
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from bp.post.admin.routes import _post_to_edit_dict
|
||||
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"
|
||||
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
|
||||
return _post_settings_content_sx(tctx)
|
||||
|
||||
|
||||
async def _h_post_edit_content(slug=None, **kw):
|
||||
await _ensure_post_data(slug)
|
||||
from quart import g, request
|
||||
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
|
||||
from bp.post.admin.routes import _post_to_edit_dict
|
||||
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
|
||||
return _post_edit_content_sx(tctx)
|
||||
|
||||
|
||||
# --- Settings helpers ---
|
||||
|
||||
async def _h_settings_content(**kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _settings_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
return _settings_main_panel_sx(tctx)
|
||||
|
||||
|
||||
async def _h_cache_content(**kw):
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _cache_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
return _cache_main_panel_sx(tctx)
|
||||
|
||||
|
||||
# --- Snippets helper ---
|
||||
|
||||
async def _h_snippets_content(**kw):
|
||||
from quart import g
|
||||
from sqlalchemy import select, or_
|
||||
from models import Snippet
|
||||
uid = g.user.id
|
||||
is_admin = g.rights.get("admin")
|
||||
filters = [Snippet.user_id == uid, Snippet.visibility == "shared"]
|
||||
if is_admin:
|
||||
filters.append(Snippet.visibility == "admin")
|
||||
rows = (await g.s.execute(
|
||||
select(Snippet).where(or_(*filters)).order_by(Snippet.name)
|
||||
)).scalars().all()
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _snippets_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["snippets"] = rows
|
||||
tctx["is_admin"] = is_admin
|
||||
return _snippets_main_panel_sx(tctx)
|
||||
|
||||
|
||||
# --- Menu Items helper ---
|
||||
|
||||
async def _h_menu_items_content(**kw):
|
||||
from quart import g
|
||||
from bp.menu_items.services.menu_items import get_all_menu_items
|
||||
menu_items = await get_all_menu_items(g.s)
|
||||
from shared.sx.page import get_template_context
|
||||
from sx.sx_components import _menu_items_main_panel_sx
|
||||
tctx = await get_template_context()
|
||||
tctx["menu_items"] = menu_items
|
||||
return _menu_items_main_panel_sx(tctx)
|
||||
|
||||
|
||||
# --- Tag Groups helpers ---
|
||||
|
||||
async def _h_tag_groups_content(**kw):
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup
|
||||
from bp.blog.admin.routes import _unassigned_tags
|
||||
groups = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroup).order_by(TagGroup.sort_order, TagGroup.name)
|
||||
)).scalars()
|
||||
)
|
||||
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})
|
||||
return _tag_groups_main_panel_sx(tctx)
|
||||
|
||||
|
||||
async def _h_tag_group_edit_content(id=None, **kw):
|
||||
from quart import g, abort
|
||||
from sqlalchemy import select
|
||||
from models.tag_group import TagGroup, TagGroupTag
|
||||
from models.ghost_content import Tag
|
||||
tg = await g.s.get(TagGroup, id)
|
||||
if not tg:
|
||||
abort(404)
|
||||
assigned_rows = list(
|
||||
(await g.s.execute(
|
||||
select(TagGroupTag.tag_id).where(TagGroupTag.tag_group_id == 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),
|
||||
})
|
||||
return _tag_groups_edit_main_panel_sx(tctx)
|
||||
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 (absolute paths under /<slug>/admin/) ---
|
||||
|
||||
(defpage post-admin
|
||||
:path "/<slug>/admin/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "admin")
|
||||
:content (post-admin-content slug))
|
||||
|
||||
(defpage post-data
|
||||
:path "/<slug>/admin/data/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "data")
|
||||
:content (post-data-content slug))
|
||||
|
||||
(defpage post-preview
|
||||
:path "/<slug>/admin/preview/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "preview")
|
||||
:content (post-preview-content slug))
|
||||
|
||||
(defpage post-entries
|
||||
:path "/<slug>/admin/entries/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "entries")
|
||||
:content (post-entries-content slug))
|
||||
|
||||
(defpage post-settings
|
||||
:path "/<slug>/admin/settings/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "settings")
|
||||
:content (post-settings-content slug))
|
||||
|
||||
(defpage post-edit
|
||||
:path "/<slug>/admin/edit/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "edit")
|
||||
:content (post-edit-content slug))
|
||||
|
||||
; --- Settings pages (absolute paths) ---
|
||||
|
||||
(defpage settings-home
|
||||
:path "/settings/"
|
||||
:auth :admin
|
||||
:layout :blog-settings
|
||||
:content (settings-content))
|
||||
|
||||
(defpage cache-page
|
||||
:path "/settings/cache/"
|
||||
:auth :admin
|
||||
:layout :blog-cache
|
||||
:content (cache-content))
|
||||
|
||||
; --- Snippets ---
|
||||
|
||||
(defpage snippets-page
|
||||
:path "/settings/snippets/"
|
||||
:auth :login
|
||||
:layout :blog-snippets
|
||||
:content (snippets-content))
|
||||
|
||||
; --- Menu Items ---
|
||||
|
||||
(defpage menu-items-page
|
||||
:path "/settings/menu_items/"
|
||||
:auth :admin
|
||||
:layout :blog-menu-items
|
||||
:content (menu-items-content))
|
||||
|
||||
; --- Tag Groups ---
|
||||
|
||||
(defpage tag-groups-page
|
||||
:path "/settings/tag-groups/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-groups
|
||||
:content (tag-groups-content))
|
||||
|
||||
(defpage tag-group-edit
|
||||
:path "/settings/tag-groups/<int:id>/"
|
||||
:auth :admin
|
||||
:layout :blog-tag-group-edit
|
||||
:content (tag-group-edit-content id))
|
||||
@@ -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 %}
|
||||
@@ -1,13 +0,0 @@
|
||||
<div class="md:hidden mx-2 bg-stone-200 rounded">
|
||||
|
||||
|
||||
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
|
||||
<i class="fa-solid fa-filter"></i>
|
||||
</span>
|
||||
<span>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"
|
||||
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
|
||||
<path d="M6 9l6 6 6-6" fill="currentColor"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,14 +0,0 @@
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
|
||||
{% call layout.details('/filter', 'md:hidden') %}
|
||||
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
|
||||
{% include '_types/blog/mobile/_filter/summary/tag_groups.html' %}
|
||||
{% include '_types/blog/mobile/_filter/summary/authors.html' %}
|
||||
{% endcall %}
|
||||
{% include '_types/blog/_action_buttons.html' %}
|
||||
<div id="filter-details-mobile" style="display:contents">
|
||||
{% include '_types/blog/desktop/menu/tag_groups.html' %}
|
||||
{% include '_types/blog/desktop/menu/authors.html' %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{% if selected_authors and selected_authors|length %}
|
||||
<ul class="relative inline-flex flex-col gap-2">
|
||||
{% for st in selected_authors %}
|
||||
{% for author in authors %}
|
||||
{% if st == author.slug %}
|
||||
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
|
||||
{% if author.profile_image %}
|
||||
<img
|
||||
src="{{ author.profile_image }}"
|
||||
alt="{{ author.name }}"
|
||||
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
{% else %}
|
||||
{# optional fallback circle with first letter #}
|
||||
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
|
||||
{{ author.name[:1] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||
{{ author.name }}
|
||||
</span>
|
||||
<span>
|
||||
{{author.published_post_count}}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -1,33 +0,0 @@
|
||||
{% if selected_groups and selected_groups|length %}
|
||||
<ul class="relative inline-flex flex-col gap-2">
|
||||
{% for sg in selected_groups %}
|
||||
{% for group in tag_groups %}
|
||||
{% if sg == group.slug %}
|
||||
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
|
||||
{% if group.feature_image %}
|
||||
<img
|
||||
src="{{ group.feature_image }}"
|
||||
alt="{{ group.name }}"
|
||||
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
{% else %}
|
||||
<div
|
||||
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
|
||||
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
|
||||
>
|
||||
{{ group.name[:1] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||
{{ group.name }}
|
||||
</span>
|
||||
<span>
|
||||
{{group.post_count}}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -1,31 +0,0 @@
|
||||
{% if selected_tags and selected_tags|length %}
|
||||
<ul class="relative inline-flex flex-col gap-2">
|
||||
{% for st in selected_tags %}
|
||||
{% for tag in tags %}
|
||||
{% if st == tag.slug %}
|
||||
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
|
||||
{% if tag.feature_image %}
|
||||
<img
|
||||
src="{{ tag.feature_image }}"
|
||||
alt="{{ tag.name }}"
|
||||
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
{% else %}
|
||||
{# optional fallback circle with first letter #}
|
||||
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
|
||||
{{ tag.name[:1] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
<span>
|
||||
{{tag.published_post_count}}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh] p-8 text-center">
|
||||
<div class="text-6xl mb-4">📝</div>
|
||||
<h1 class="text-2xl font-bold text-stone-800 mb-2">Post Not Found</h1>
|
||||
<p class="text-stone-600 mb-6">
|
||||
The post "{{ slug }}" could not be found.
|
||||
</p>
|
||||
<a
|
||||
href="{{ url_for('blog.index')|host }}"
|
||||
sx-get="{{ url_for('blog.index')|host }}"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{ hx_select }}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors"
|
||||
>
|
||||
← Back to Blog
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,55 +0,0 @@
|
||||
<div class="p-4 space-y-4 max-w-4xl mx-auto">
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-stone-800">Drafts</h2>
|
||||
{% set new_href = url_for('blog.new_post')|host %}
|
||||
<a
|
||||
href="{{ new_href }}"
|
||||
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"
|
||||
>
|
||||
<i class="fa fa-plus mr-1"></i> New Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if drafts %}
|
||||
<div class="space-y-3">
|
||||
{% for draft in drafts %}
|
||||
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
|
||||
<a
|
||||
href="{{ edit_href }}"
|
||||
sx-disable
|
||||
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-stone-900 truncate">
|
||||
{{ draft.title or "Untitled" }}
|
||||
</h3>
|
||||
{% if draft.excerpt %}
|
||||
<p class="text-stone-600 text-sm mt-1 line-clamp-2">
|
||||
{{ draft.excerpt }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if draft.updated_at %}
|
||||
<p class="text-xs text-stone-400 mt-2">
|
||||
Updated: {{ draft.updated_at.strftime("%-d %b %Y at %H:%M") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 flex-shrink-0">
|
||||
Draft
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-stone-500 text-center py-8">No drafts yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -1,12 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/blog/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/blog_drafts/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,11 +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('root-blog-header', '_types/blog/header/_header.html') %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/blog_drafts/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,259 +0,0 @@
|
||||
{# ── Error banner ── #}
|
||||
{% if save_error %}
|
||||
<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">
|
||||
<strong>Save failed:</strong> {{ save_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" id="lexical-json-input" name="lexical" value="">
|
||||
<input type="hidden" id="feature-image-input" name="feature_image" value="">
|
||||
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">
|
||||
|
||||
{# ── Feature image ── #}
|
||||
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
|
||||
{# Empty state: add link #}
|
||||
<div id="feature-image-empty">
|
||||
<button
|
||||
type="button"
|
||||
id="feature-image-add-btn"
|
||||
class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||
>+ Add feature image</button>
|
||||
</div>
|
||||
|
||||
{# Filled state: image preview + controls #}
|
||||
<div id="feature-image-filled" class="relative hidden">
|
||||
<img
|
||||
id="feature-image-preview"
|
||||
src=""
|
||||
alt=""
|
||||
class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer"
|
||||
>
|
||||
{# Delete button (top-right, visible on hover) #}
|
||||
<button
|
||||
type="button"
|
||||
id="feature-image-delete-btn"
|
||||
class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white
|
||||
flex items-center justify-center opacity-0 group-hover:opacity-100
|
||||
transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
|
||||
title="Remove feature image"
|
||||
><i class="fa-solid fa-trash-can"></i></button>
|
||||
|
||||
{# Caption input #}
|
||||
<input
|
||||
type="text"
|
||||
id="feature-image-caption"
|
||||
value=""
|
||||
placeholder="Add a caption..."
|
||||
class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none
|
||||
outline-none placeholder:text-stone-300 focus:text-stone-700"
|
||||
>
|
||||
</div>
|
||||
|
||||
{# Upload spinner overlay #}
|
||||
<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> Uploading...
|
||||
</div>
|
||||
|
||||
{# Hidden file input #}
|
||||
<input
|
||||
type="file"
|
||||
id="feature-image-file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
|
||||
class="hidden"
|
||||
>
|
||||
</div>
|
||||
|
||||
{# ── Title ── #}
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value=""
|
||||
placeholder="{{ 'Page title...' if is_page else 'Post title...' }}"
|
||||
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
|
||||
placeholder:text-stone-300 mb-[8px] leading-tight"
|
||||
>
|
||||
|
||||
{# ── Excerpt ── #}
|
||||
<textarea
|
||||
name="custom_excerpt"
|
||||
rows="1"
|
||||
placeholder="Add an excerpt..."
|
||||
class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none
|
||||
placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
|
||||
></textarea>
|
||||
|
||||
{# ── Editor mount point ── #}
|
||||
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
|
||||
|
||||
{# ── Status + Save footer ── #}
|
||||
<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
|
||||
<select
|
||||
name="status"
|
||||
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
|
||||
>
|
||||
<option value="draft" selected>Draft</option>
|
||||
<option value="published">Published</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
|
||||
hover:bg-stone-800 transition-colors cursor-pointer"
|
||||
>{{ 'Create Page' if is_page else 'Create Post' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ── Koenig editor assets ── #}
|
||||
<link rel="stylesheet" href="{{ asset_url('scripts/editor.css') }}">
|
||||
<style>
|
||||
/* Koenig CSS uses rem, designed for Ghost Admin's html{font-size:62.5%}.
|
||||
We apply that via JS (see init() below) so the header bars render at
|
||||
normal size on first paint. A beforeSwap listener restores the
|
||||
default when navigating away. */
|
||||
#lexical-editor { display: flow-root; }
|
||||
/* Reset floats inside HTML cards to match Ghost Admin behaviour */
|
||||
#lexical-editor [data-kg-card="html"] * { float: none !important; }
|
||||
#lexical-editor [data-kg-card="html"] table { width: 100% !important; }
|
||||
</style>
|
||||
<script src="{{ asset_url('scripts/editor.js') }}"></script>
|
||||
<script>
|
||||
(function() {
|
||||
/* ── Koenig rem fix: apply 62.5% root font-size for the editor,
|
||||
restore default when navigating away via HTMX ── */
|
||||
function applyEditorFontSize() {
|
||||
document.documentElement.style.fontSize = '62.5%';
|
||||
document.body.style.fontSize = '1.6rem';
|
||||
}
|
||||
function restoreDefaultFontSize() {
|
||||
document.documentElement.style.fontSize = '';
|
||||
document.body.style.fontSize = '';
|
||||
}
|
||||
applyEditorFontSize();
|
||||
document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {
|
||||
if (e.detail.target && e.detail.target.id === 'main-panel') {
|
||||
restoreDefaultFontSize();
|
||||
document.body.removeEventListener('htmx:beforeSwap', cleanup);
|
||||
}
|
||||
});
|
||||
|
||||
function init() {
|
||||
var csrfToken = document.querySelector('input[name="csrf_token"]').value;
|
||||
var uploadUrl = '{{ url_for("blog.editor_api.upload_image") }}';
|
||||
var uploadUrls = {
|
||||
image: uploadUrl,
|
||||
media: '{{ url_for("blog.editor_api.upload_media") }}',
|
||||
file: '{{ url_for("blog.editor_api.upload_file") }}',
|
||||
};
|
||||
|
||||
/* ── Feature image upload / delete / replace ── */
|
||||
var fileInput = document.getElementById('feature-image-file');
|
||||
var addBtn = document.getElementById('feature-image-add-btn');
|
||||
var deleteBtn = document.getElementById('feature-image-delete-btn');
|
||||
var preview = document.getElementById('feature-image-preview');
|
||||
var emptyState = document.getElementById('feature-image-empty');
|
||||
var filledState = document.getElementById('feature-image-filled');
|
||||
var hiddenUrl = document.getElementById('feature-image-input');
|
||||
var hiddenCaption = document.getElementById('feature-image-caption-input');
|
||||
var captionInput = document.getElementById('feature-image-caption');
|
||||
var uploading = document.getElementById('feature-image-uploading');
|
||||
|
||||
function showFilled(url) {
|
||||
preview.src = url;
|
||||
hiddenUrl.value = url;
|
||||
emptyState.classList.add('hidden');
|
||||
filledState.classList.remove('hidden');
|
||||
uploading.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
preview.src = '';
|
||||
hiddenUrl.value = '';
|
||||
hiddenCaption.value = '';
|
||||
captionInput.value = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
filledState.classList.add('hidden');
|
||||
uploading.classList.add('hidden');
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
emptyState.classList.add('hidden');
|
||||
uploading.classList.remove('hidden');
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('Upload failed (' + r.status + ')');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
var url = data.images && data.images[0] && data.images[0].url;
|
||||
if (url) showFilled(url);
|
||||
else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }
|
||||
})
|
||||
.catch(function(e) {
|
||||
showEmpty();
|
||||
alert(e.message);
|
||||
});
|
||||
}
|
||||
|
||||
addBtn.addEventListener('click', function() { fileInput.click(); });
|
||||
preview.addEventListener('click', function() { fileInput.click(); });
|
||||
deleteBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
showEmpty();
|
||||
});
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
uploadFile(fileInput.files[0]);
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
captionInput.addEventListener('input', function() {
|
||||
hiddenCaption.value = captionInput.value;
|
||||
});
|
||||
|
||||
/* ── Auto-resize excerpt textarea ── */
|
||||
var excerpt = document.querySelector('textarea[name="custom_excerpt"]');
|
||||
function autoResize() {
|
||||
excerpt.style.height = 'auto';
|
||||
excerpt.style.height = excerpt.scrollHeight + 'px';
|
||||
}
|
||||
excerpt.addEventListener('input', autoResize);
|
||||
autoResize();
|
||||
|
||||
/* ── Mount Koenig editor ── */
|
||||
window.mountEditor('lexical-editor', {
|
||||
initialJson: null,
|
||||
csrfToken: csrfToken,
|
||||
uploadUrls: uploadUrls,
|
||||
oembedUrl: '{{ url_for("blog.editor_api.oembed_proxy") }}',
|
||||
unsplashApiKey: '{{ unsplash_api_key or "" }}',
|
||||
snippetsUrl: '{{ url_for("blog.editor_api.list_snippets") }}',
|
||||
});
|
||||
|
||||
/* ── Ctrl-S / Cmd-S to save ── */
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
document.getElementById('post-new-form').requestSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* editor.js loads synchronously on full page loads but asynchronously
|
||||
when HTMX swaps the content in, so wait for it if needed. */
|
||||
if (typeof window.mountEditor === 'function') {
|
||||
init();
|
||||
} else {
|
||||
var _t = setInterval(function() {
|
||||
if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }
|
||||
}, 50);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/blog/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/blog_new/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,11 +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('root-blog-header', '_types/blog/header/_header.html') %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/blog_new/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="relative">
|
||||
<div class="blog-content p-2">
|
||||
{% if post.html %}
|
||||
{{post.html|safe}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -1,14 +0,0 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
{% block meta %}
|
||||
{% include '_types/post/_meta.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="relative">
|
||||
<div class="blog-content p-2">
|
||||
{% if post.html %}
|
||||
{{post.html|safe}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -1,125 +0,0 @@
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6" id="menu-item-form-container">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">
|
||||
{% if menu_item %}Edit{% else %}Add{% endif %} Menu Item
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('menu-item-form').innerHTML = ''"
|
||||
class="text-stone-400 hover:text-stone-600">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Hidden field for selected post ID - outside form for JS access #}
|
||||
<input type="hidden" name="post_id" id="selected-post-id" value="{{ menu_item.container_id if menu_item else '' }}" />
|
||||
|
||||
{# Selected page display #}
|
||||
{% if menu_item %}
|
||||
<div id="selected-page-display" class="mb-3 p-3 bg-stone-50 rounded flex items-center gap-3">
|
||||
{% if menu_item.feature_image %}
|
||||
<img src="{{ menu_item.feature_image }}"
|
||||
alt="{{ menu_item.label }}"
|
||||
class="w-10 h-10 rounded-full object-cover" />
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full bg-stone-200"></div>
|
||||
{% endif %}
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ menu_item.label }}</div>
|
||||
<div class="text-xs text-stone-500">{{ menu_item.slug }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="selected-page-display" class="mb-3 hidden">
|
||||
{# Will be populated by JavaScript when page selected #}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Form for submission #}
|
||||
<form
|
||||
{% if menu_item %}
|
||||
sx-put="{{ url_for('menu_items.update_menu_item_route', item_id=menu_item.id) }}"
|
||||
{% else %}
|
||||
sx-post="{{ url_for('menu_items.create_menu_item_route') }}"
|
||||
{% endif %}
|
||||
sx-target="#menu-items-list"
|
||||
sx-swap="innerHTML"
|
||||
sx-include="#selected-post-id"
|
||||
sx-on:afterRequest="if(event.detail.successful) { document.getElementById('menu-item-form').innerHTML = '' }"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{# Form actions #}
|
||||
<div class="flex gap-2 pb-3 border-b">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<i class="fa fa-save"></i> Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('menu-item-form').innerHTML = ''"
|
||||
class="px-4 py-2 border border-stone-300 rounded hover:bg-stone-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Search section - outside form to prevent interference #}
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-stone-700 mb-2">
|
||||
Select Page
|
||||
</label>
|
||||
|
||||
{# Search input #}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for a page... (or leave blank for all)"
|
||||
sx-get="{{ url_for('menu_items.search_pages_route') }}"
|
||||
sx-trigger="keyup changed delay:300ms, focus once"
|
||||
sx-target="#page-search-results"
|
||||
sx-swap="innerHTML"
|
||||
name="q"
|
||||
id="page-search-input"
|
||||
class="w-full px-3 py-2 border border-stone-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
|
||||
{# Search results container #}
|
||||
<div id="page-search-results" class="mt-2">
|
||||
{# Search results will appear here #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Handle page selection
|
||||
document.addEventListener('click', function(e) {
|
||||
const pageOption = e.target.closest('[data-page-id]');
|
||||
if (pageOption) {
|
||||
const postId = pageOption.dataset.pageId;
|
||||
const postTitle = pageOption.dataset.pageTitle;
|
||||
const postSlug = pageOption.dataset.pageSlug;
|
||||
const postImage = pageOption.dataset.pageImage;
|
||||
|
||||
// Update hidden field
|
||||
document.getElementById('selected-post-id').value = postId;
|
||||
|
||||
// Update display
|
||||
const display = document.getElementById('selected-page-display');
|
||||
display.innerHTML = `
|
||||
<div class="p-3 bg-stone-50 rounded flex items-center gap-3">
|
||||
${postImage ?
|
||||
`<img src="${postImage}" alt="${postTitle}" class="w-10 h-10 rounded-full object-cover" />` :
|
||||
`<div class="w-10 h-10 rounded-full bg-stone-200"></div>`
|
||||
}
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">${postTitle}</div>
|
||||
<div class="text-xs text-stone-500">${postSlug}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
display.classList.remove('hidden');
|
||||
|
||||
// Clear search results
|
||||
document.getElementById('page-search-results').innerHTML = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,68 +0,0 @@
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
{% if menu_items %}
|
||||
<div class="divide-y">
|
||||
{% for item in menu_items %}
|
||||
<div class="flex items-center gap-4 p-4 hover:bg-stone-50 transition">
|
||||
{# Drag handle #}
|
||||
<div class="text-stone-400 cursor-move">
|
||||
<i class="fa fa-grip-vertical"></i>
|
||||
</div>
|
||||
|
||||
{# Page image #}
|
||||
{% if item.feature_image %}
|
||||
<img src="{{ item.feature_image }}"
|
||||
alt="{{ item.label }}"
|
||||
class="w-12 h-12 rounded-full object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
|
||||
{# Page title #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ item.label }}</div>
|
||||
<div class="text-xs text-stone-500 truncate">{{ item.slug }}</div>
|
||||
</div>
|
||||
|
||||
{# Sort order #}
|
||||
<div class="text-sm text-stone-500">
|
||||
Order: {{ item.sort_order }}
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
sx-get="{{ url_for('menu_items.edit_menu_item', item_id=item.id) }}"
|
||||
sx-target="#menu-item-form"
|
||||
sx-swap="innerHTML"
|
||||
class="px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded">
|
||||
<i class="fa fa-edit"></i> Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-confirm
|
||||
data-confirm-title="Delete menu item?"
|
||||
data-confirm-text="Remove {{ item.label }} from the menu?"
|
||||
data-confirm-icon="warning"
|
||||
data-confirm-confirm-text="Yes, delete"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
sx-delete="{{ url_for('menu_items.delete_menu_item_route', item_id=item.id) }}"
|
||||
sx-trigger="confirmed"
|
||||
sx-target="#menu-items-list"
|
||||
sx-swap="innerHTML"
|
||||
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
class="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800">
|
||||
<i class="fa fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-stone-400">
|
||||
<i class="fa fa-inbox text-4xl mb-2"></i>
|
||||
<p>No menu items yet. Add one to get started!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<div class="mb-6 flex justify-end items-center">
|
||||
<button
|
||||
type="button"
|
||||
sx-get="{{ url_for('menu_items.new_menu_item') }}"
|
||||
sx-target="#menu-item-form"
|
||||
sx-swap="innerHTML"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<i class="fa fa-plus"></i> Add Menu Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Form container #}
|
||||
<div id="menu-item-form" class="mb-6"></div>
|
||||
|
||||
{# Menu items list #}
|
||||
<div id="menu-items-list">
|
||||
{% include '_types/menu_items/_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,31 +0,0 @@
|
||||
{% set _app_slugs = {'cart': cart_url('/')} %}
|
||||
{% set _first_seg = request.path.strip('/').split('/')[0] %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="menu-items-nav-wrapper"
|
||||
sx-swap-oob="outerHTML">
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
|
||||
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
|
||||
<a
|
||||
href="{{ _href }}"
|
||||
{% if item.slug not in _app_slugs %}
|
||||
sx-get="/{{ item.slug }}/"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{ hx_select_search }}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
{% endif %}
|
||||
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
|
||||
class="{{styles.nav_button}}"
|
||||
>
|
||||
{% if item.feature_image %}
|
||||
<img src="{{ item.feature_image }}"
|
||||
alt="{{ item.label }}"
|
||||
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
{% endcall %}
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('root-settings-header-child', 'menu_items-header-child', '_types/menu_items/header/_header.html')}}
|
||||
|
||||
{% from '_types/root/settings/header/_header.html' import header_row with context %}
|
||||
{{header_row(oob=True)}}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block mobile_menu %}
|
||||
{#% include '_types/root/settings/_nav.html' %#}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/menu_items/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{% if pages %}
|
||||
<div class="border border-stone-200 rounded-md max-h-64 overflow-y-auto">
|
||||
{% for post in pages %}
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
|
||||
data-page-id="{{ post.id }}"
|
||||
data-page-title="{{ post.title }}"
|
||||
data-page-slug="{{ post.slug }}"
|
||||
data-page-image="{{ post.feature_image or '' }}">
|
||||
|
||||
{# Page image #}
|
||||
{% if post.feature_image %}
|
||||
<img src="{{ post.feature_image }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-10 h-10 rounded-full object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
|
||||
{# Page info #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ post.title }}</div>
|
||||
<div class="text-xs text-stone-500 truncate">{{ post.slug }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Infinite scroll sentinel #}
|
||||
{% if has_more %}
|
||||
<div
|
||||
sx-get="{{ url_for('menu_items.search_pages_route') }}"
|
||||
sx-trigger="intersect once"
|
||||
sx-swap="outerHTML"
|
||||
sx-vals='{"q": "{{ query }}", "page": {{ page + 1 }}}'
|
||||
class="p-3 text-center text-sm text-stone-400">
|
||||
<i class="fa fa-spinner fa-spin"></i> Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif query %}
|
||||
<div class="p-3 text-center text-stone-400 border border-stone-200 rounded-md">
|
||||
No pages found matching "{{ query }}"
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,9 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='menu_items-row', oob=oob) %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{ admin_nav_item(url_for('menu_items.list_menu_items'), 'bars', 'Menu Items', select_colours, aclass='') }}
|
||||
{% call links.desktop_nav() %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -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/menu_items/header/_header.html' import header_row with context %}
|
||||
{{ header_row() }}
|
||||
<div id="menu_items-header-child">
|
||||
{% block menu_items_header_child %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/menu_items/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% endblock %}
|
||||
@@ -1,18 +0,0 @@
|
||||
<div id="associated-entries-container"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
style="scroll-behavior: smooth;"
|
||||
data-scroll-arrows="entries-nav-arrow">
|
||||
<div class="flex flex-col sm:flex-row gap-1">
|
||||
{% include '_types/post/_entry_items.html' with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
{# Get entries from either direct variable or associated_entries dict #}
|
||||
{% set entry_list = entries if entries is defined else (associated_entries.entries if associated_entries is defined else []) %}
|
||||
{% set current_page = page if page is defined else (associated_entries.page if associated_entries is defined else 1) %}
|
||||
{% set has_more_entries = has_more if has_more is defined else (associated_entries.has_more if associated_entries is defined else False) %}
|
||||
|
||||
{% for entry in entry_list %}
|
||||
{% set _entry_path = '/' + post.slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
|
||||
<a
|
||||
href="{{ events_url(_entry_path) }}"
|
||||
class="{{styles.nav_button_less_pad}}"
|
||||
>
|
||||
{% if post.feature_image %}
|
||||
<img src="{{ post.feature_image }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 truncate">
|
||||
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{# Load more entries one at a time until container is full #}
|
||||
{% if has_more_entries %}
|
||||
<div id="entries-load-sentinel-{{ current_page }}"
|
||||
sx-get="{{ url_for('blog.post.get_entries', slug=post.slug, page=current_page + 1) }}"
|
||||
sx-trigger="intersect once"
|
||||
sx-swap="beforebegin"
|
||||
sx-on:afterSwap="document.querySelector('#associated-entries-container').dispatchEvent(new Event('scroll'))"
|
||||
class="flex-shrink-0 w-1">
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,65 +0,0 @@
|
||||
{# Main panel fragment for HTMX navigation - post/page article content #}
|
||||
<article class="relative">
|
||||
{# Draft indicator + edit link (shown for both posts and pages) #}
|
||||
{% if post.status == "draft" %}
|
||||
<div class="flex items-center justify-center gap-2 mb-3">
|
||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800">Draft</span>
|
||||
{% if post.publish_requested %}
|
||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800">Publish requested</span>
|
||||
{% endif %}
|
||||
{% set is_admin = (g.get("rights") or {}).get("admin") %}
|
||||
{% if is_admin or (g.user and post.user_id == g.user.id) %}
|
||||
{% set edit_href = url_for('blog.post.admin.edit', slug=post.slug)|host %}
|
||||
<a
|
||||
href="{{ edit_href }}"
|
||||
sx-get="{{ edit_href }}"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{hx_select_search}}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
class="inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
<i class="fa fa-pencil mr-1"></i> Edit
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not post.is_page %}
|
||||
{# ── Blog post chrome: like button, excerpt, tags/authors ── #}
|
||||
{% if g.user %}
|
||||
<div class="absolute top-2 right-2 z-10 text-8xl md:text-6xl">
|
||||
{% set slug = post.slug %}
|
||||
{% set liked = post.is_liked or False %}
|
||||
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
|
||||
{% set item_type = 'post' %}
|
||||
{% include "_types/browse/like/button.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if post.custom_excerpt %}
|
||||
<div class="w-full text-center italic text-3xl p-2">
|
||||
{{post.custom_excerpt|safe}}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="hidden md:block">
|
||||
{% include '_types/blog/_card/at_bar.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if post.feature_image %}
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img
|
||||
src="{{ post.feature_image }}"
|
||||
alt=""
|
||||
class="rounded-lg w-full md:w-3/4 object-cover"
|
||||
>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="blog-content p-2">
|
||||
{% if post.html %}
|
||||
{{post.html|safe}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
<div class="pb-8"></div>
|
||||
@@ -1,124 +0,0 @@
|
||||
{# --- social/meta_post.html --- #}
|
||||
{# Context expected:
|
||||
site, post, request
|
||||
#}
|
||||
|
||||
{# Visibility → robots #}
|
||||
{% set is_public = (post.visibility == 'public') %}
|
||||
{% set is_published = (post.status == 'published') %}
|
||||
{% set robots_here = 'index,follow' if (is_public and is_published and not post.email_only) else 'noindex,nofollow' %}
|
||||
|
||||
{# Compute canonical early so both this file and base can use it #}
|
||||
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
|
||||
{% set _post_path = request.path if request else ('/posts/' ~ (post.slug or post.uuid)) %}
|
||||
{% set canonical = post.canonical_url or (_site_url ~ _post_path if _site_url else (request.url if request else None)) %}
|
||||
|
||||
{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #}
|
||||
{% set robots_override = robots_here %}
|
||||
{% include 'social/meta_base.html' %}
|
||||
|
||||
{# ---- Titles / descriptions ---- #}
|
||||
{% set og_title = post.og_title or base_title %}
|
||||
{% set tw_title = post.twitter_title or base_title %}
|
||||
|
||||
{# Description best-effort, trimmed #}
|
||||
{% set desc_source = post.meta_description
|
||||
or post.og_description
|
||||
or post.twitter_description
|
||||
or post.custom_excerpt
|
||||
or post.excerpt
|
||||
or (post.plaintext if post.plaintext else (post.html|striptags if post.html else '')) %}
|
||||
{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %}
|
||||
|
||||
{# Image priority #}
|
||||
{% set image_url = post.og_image
|
||||
or post.twitter_image
|
||||
or post.feature_image
|
||||
or (site().default_image if site and site().default_image else None) %}
|
||||
|
||||
{# Dates #}
|
||||
{% set published_iso = post.published_at.isoformat() if post.published_at else None %}
|
||||
{% set updated_iso = post.updated_at.isoformat() if post.updated_at
|
||||
else (post.created_at.isoformat() if post.created_at else None) %}
|
||||
|
||||
{# Authors / tags #}
|
||||
{% set primary_author = post.primary_author %}
|
||||
{% set authors = post.authors or ([primary_author] if primary_author else []) %}
|
||||
{% set tag_names = (post.tags or []) | map(attribute='name') | list %}
|
||||
{% set is_article = not post.is_page %}
|
||||
|
||||
<title>{{ base_title }}</title>
|
||||
<meta name="description" content="{{ description }}">
|
||||
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
|
||||
|
||||
{# ---- Open Graph ---- #}
|
||||
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
|
||||
<meta property="og:type" content="{{ 'article' if is_article else 'website' }}">
|
||||
<meta property="og:title" content="{{ og_title }}">
|
||||
<meta property="og:description" content="{{ description }}">
|
||||
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
|
||||
{% if image_url %}<meta property="og:image" content="{{ image_url }}">{% endif %}
|
||||
{% if is_article and published_iso %}<meta property="article:published_time" content="{{ published_iso }}">{% endif %}
|
||||
{% if is_article and updated_iso %}
|
||||
<meta property="article:modified_time" content="{{ updated_iso }}">
|
||||
<meta property="og:updatd_time" content="{{ updated_iso }}">
|
||||
{% endif %}
|
||||
{% if is_article and post.primary_tag and post.primary_tag.name %}
|
||||
<meta property="article:section" content="{{ post.primary_tag.name }}">
|
||||
{% endif %}
|
||||
{% if is_article %}
|
||||
{% for t in tag_names %}
|
||||
<meta property="article:tag" content="{{ t }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- Twitter ---- #}
|
||||
<meta name="twitter:card" content="{{ 'summary_large_image' if image_url else 'summary' }}">
|
||||
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
|
||||
{% if primary_author and primary_author.twitter %}
|
||||
<meta name="twitter:creator" content="@{{ primary_author.twitter | replace('@','') }}">
|
||||
{% endif %}
|
||||
<meta name="twitter:title" content="{{ tw_title }}">
|
||||
<meta name="twitter:description" content="{{ description }}">
|
||||
{% if image_url %}<meta name="twitter:image" content="{{ image_url }}">{% endif %}
|
||||
|
||||
{# ---- JSON-LD author value (no list comprehensions) ---- #}
|
||||
{% if authors and authors|length == 1 %}
|
||||
{% set author_value = {"@type": "Person", "name": authors[0].name} %}
|
||||
{% elif authors %}
|
||||
{% set ns = namespace(arr=[]) %}
|
||||
{% for a in authors %}
|
||||
{% set _ = ns.arr.append({"@type": "Person", "name": a.name}) %}
|
||||
{% endfor %}
|
||||
{% set author_value = ns.arr %}
|
||||
{% else %}
|
||||
{% set author_value = none %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- JSON-LD using combine for optionals ---- #}
|
||||
{% set jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting" if is_article else "WebPage",
|
||||
"mainEntityOfPage": canonical,
|
||||
"headline": base_title,
|
||||
"description": description,
|
||||
"image": image_url,
|
||||
"datePublished": published_iso,
|
||||
"author": author_value,
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": site().title if site and site().title else "",
|
||||
"logo": {"@type": "ImageObject", "url": site().logo if site and site().logo else image_url}
|
||||
}
|
||||
} %}
|
||||
|
||||
{% if updated_iso %}
|
||||
{% set jsonld = jsonld | combine({"dateModified": updated_iso}) %}
|
||||
{% endif %}
|
||||
{% if tag_names %}
|
||||
{% set jsonld = jsonld | combine({"keywords": tag_names | join(", ")}) %}
|
||||
{% endif %}
|
||||
|
||||
<script type="application/ld+json">
|
||||
{{ jsonld | tojson }}
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{# Container nav from fragments (calendars, markets) #}
|
||||
{% if container_nav_html %}
|
||||
<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_html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Admin link #}
|
||||
{% if post and has_access('blog.post.admin.admin') %}
|
||||
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
@@ -1,36 +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_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import header with context %}
|
||||
{% call header(id='root-header-child', oob=True) %}
|
||||
{% call header() %}
|
||||
{% from '_types/post/header/_header.html' import header_row with context %}
|
||||
{{header_row()}}
|
||||
<div id="post-header-child">
|
||||
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
|
||||
{# Mobile menu #}
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/post/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/post/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,50 +0,0 @@
|
||||
<div id="associated-entries-list" class="border rounded-lg p-4 bg-white">
|
||||
<h3 class="text-lg font-semibold mb-4">Associated Entries</h3>
|
||||
{% if associated_entry_ids %}
|
||||
<div class="space-y-1">
|
||||
{% for calendar in all_calendars %}
|
||||
{% for entry in calendar.entries %}
|
||||
{% if entry.id in associated_entry_ids and entry.deleted_at is none %}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
|
||||
data-confirm
|
||||
data-confirm-title="Remove entry?"
|
||||
data-confirm-text="This will remove {{ entry.name }} from this post"
|
||||
data-confirm-icon="warning"
|
||||
data-confirm-confirm-text="Yes, remove it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
sx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}"
|
||||
sx-trigger="confirmed"
|
||||
sx-target="#associated-entries-list"
|
||||
sx-swap="outerHTML"
|
||||
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
sx-on:afterSwap="document.body.dispatchEvent(new CustomEvent('entryToggled'))"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
{% if calendar.post.feature_image %}
|
||||
<img src="{{ calendar.post.feature_image }}"
|
||||
alt="{{ calendar.post.title }}"
|
||||
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">{{ entry.name }}</div>
|
||||
<div class="text-xs text-stone-600 mt-1">
|
||||
{{ calendar.name }} • {{ entry.start_at.strftime('%A, %B %d, %Y at %H:%M') }}
|
||||
{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa fa-times-circle text-green-600 text-lg flex-shrink-0"></i>
|
||||
</div>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-stone-400">No entries associated yet. Browse calendars below to add entries.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user