Unify post admin nav across all services

Move post admin header into shared/sexp/helpers.py so blog, cart,
events, and market all render the same admin row with identical nav:
calendars | markets | payments | entries | data | edit | settings.

All links are external (cross-service). The selected item shows
highlighted on the right and as white text next to "admin" on the left.

- blog: delegates to shared helper, removes blog-specific nav builder
- cart: delegates to shared helper for payments admin
- events: adds shared admin row (selected=calendars) to calendar admin
- market: adds /<slug>/admin/ route + page_admin blueprint, delegates
  to shared helper (selected=markets). Fixes 404 on page-level admin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 22:01:56 +00:00
parent 2d08d6f787
commit b47ad6224b
9 changed files with 143 additions and 101 deletions

View File

@@ -16,6 +16,7 @@ from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
post_header_html as _shared_post_header_html,
post_admin_header_html as _shared_post_admin_header_html,
oob_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
@@ -87,81 +88,9 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
# ---------------------------------------------------------------------------
def _post_admin_header_html(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
"""Post admin header row with admin icon and nav links."""
from quart import url_for as qurl
post = ctx.get("post") or {}
slug = post.get("slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
admin_href = qurl("blog.post.admin.admin", slug=slug)
label_html = render("blog-admin-label")
if selected:
label_html += f' <span class="text-white">{escape(selected)}</span>'
nav_html = _post_admin_nav_html(ctx, selected=selected)
return render("menu-row",
id="post-admin-row", level=2,
link_href=admin_href, link_label_html=label_html,
nav_html=nav_html, child_id="post-admin-header-child", oob=oob,
)
def _post_admin_nav_html(ctx: dict, *, selected: str = "") -> str:
"""Post admin desktop nav: calendars, markets, payments, entries, data, edit, settings."""
from quart import url_for as qurl
post = ctx.get("post") or {}
slug = post.get("slug", "")
hx_select = ctx.get("hx_select_search", "#main-panel")
select_colours = ctx.get("select_colours", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
# Base and selected class for nav items
base_cls = "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
selected_cls = "justify-center cursor-pointer flex flex-row items-center gap-2 rounded !bg-stone-500 !text-white p-3"
parts = []
# External links to events / market services
events_url_fn = ctx.get("events_url")
market_url_fn = ctx.get("market_url")
if callable(events_url_fn):
for url_fn, path, label in [
(events_url_fn, f"/{slug}/admin/", "calendars"),
(market_url_fn, f"/{slug}/admin/", "markets"),
(ctx.get("cart_url"), f"/{slug}/admin/payments/", "payments"),
]:
if not callable(url_fn):
continue
href = url_fn(path)
cls = selected_cls if label == selected else (nav_btn or base_cls)
parts.append(render("blog-admin-nav-item",
href=href, nav_btn_class=cls, label=label,
select_colours=select_colours,
))
# HTMX links
for endpoint, label in [
("blog.post.admin.entries", "entries"),
("blog.post.admin.data", "data"),
("blog.post.admin.edit", "edit"),
("blog.post.admin.settings", "settings"),
]:
href = qurl(endpoint, slug=slug)
is_sel = label == selected
parts.append(render("nav-link",
href=href, label=label, select_colours=select_colours,
is_selected=is_sel,
aclass=(selected_cls + " " + select_colours) if is_sel else None,
))
return "".join(parts)
"""Post admin header row — delegates to shared helper."""
slug = (ctx.get("post") or {}).get("slug", "")
return _shared_post_admin_header_html(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------

View File

@@ -12,8 +12,8 @@ from markupsafe import escape
from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, root_header_html, search_desktop_html,
search_mobile_html, full_page, oob_page,
call_url, root_header_html, post_admin_header_html,
search_desktop_html, search_mobile_html, full_page, oob_page,
)
from shared.infrastructure.urls import market_product_url, cart_url
@@ -725,15 +725,9 @@ async def render_checkout_error_page(ctx: dict, error: str | None = None, order:
def _cart_page_admin_header_html(ctx: dict, page_post: Any, *, oob: bool = False,
selected: str = "") -> str:
"""Build the page-level admin header row."""
from quart import url_for
link_href = url_for("page_admin.admin")
label_html = '<i class="fa fa-cog" aria-hidden="true"></i> admin'
if selected:
label_html += f' <span class="text-white">{escape(selected)}</span>'
return render("menu-row", id="page-admin-row", level=2, colour="sky",
link_href=link_href, link_label_html=label_html,
child_id="page-admin-header-child", oob=oob)
"""Build the page-level admin header row — delegates to shared helper."""
slug = page_post.slug if page_post else ""
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
def _cart_admin_main_panel_html(ctx: dict) -> str:

View File

@@ -15,6 +15,7 @@ from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
post_header_html as _shared_post_header_html,
post_admin_header_html,
oob_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
@@ -1352,11 +1353,19 @@ async def render_day_admin_oob(ctx: dict) -> str:
# Calendar admin
# ---------------------------------------------------------------------------
def _events_post_admin_header_html(ctx: dict, *, oob: bool = False,
selected: str = "") -> str:
"""Post-level admin row for events — delegates to shared helper."""
slug = (ctx.get("post") or {}).get("slug", "")
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
async def render_calendar_admin_page(ctx: dict) -> str:
"""Full page: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _events_post_admin_header_html(ctx, selected="calendars")
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
hdr += render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
@@ -1365,7 +1374,8 @@ async def render_calendar_admin_page(ctx: dict) -> str:
async def render_calendar_admin_oob(ctx: dict) -> str:
"""OOB response: calendar admin."""
content = _calendar_admin_main_panel_html(ctx)
oobs = _calendar_header_html(ctx, oob=True)
oobs = (_events_post_admin_header_html(ctx, oob=True, selected="calendars")
+ _calendar_header_html(ctx, oob=True))
oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child",
_calendar_admin_header_html(ctx))
return oob_page(ctx, oobs_html=oobs, content_html=content)
@@ -1383,6 +1393,7 @@ async def render_slots_page(ctx: dict) -> str:
content = render_slots_table(slots, calendar)
hdr = root_header_html(ctx)
child = (_post_header_html(ctx)
+ _events_post_admin_header_html(ctx, selected="calendars")
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
hdr += render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
@@ -1393,7 +1404,8 @@ async def render_slots_oob(ctx: dict) -> str:
slots = ctx.get("slots") or []
calendar = ctx.get("calendar")
content = render_slots_table(slots, calendar)
oobs = _calendar_admin_header_html(ctx, oob=True)
oobs = (_events_post_admin_header_html(ctx, oob=True, selected="calendars")
+ _calendar_admin_header_html(ctx, oob=True))
return oob_page(ctx, oobs_html=oobs, content_html=content)

View File

@@ -11,7 +11,7 @@ from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from shared.config import config
from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments, register_actions, register_data
from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_fragments, register_actions, register_data
async def market_context() -> dict:
@@ -111,6 +111,12 @@ def create_app() -> "Quart":
url_prefix="/<slug>",
)
# Page admin: /<slug>/admin/ — post-level admin for markets
app.register_blueprint(
register_page_admin(),
url_prefix="/<slug>/admin",
)
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
app.register_blueprint(
register_market_bp(

View File

@@ -2,6 +2,7 @@ from .market.routes import register as register_market_bp
from .product.routes import register as register_product
from .all_markets.routes import register as register_all_markets
from .page_markets.routes import register as register_page_markets
from .page_admin.routes import register as register_page_admin
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from quart import make_response, Blueprint
from shared.browser.app.authz import require_admin
from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("page_admin", __name__)
@bp.get("/")
@require_admin
async def admin(**kwargs):
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_page_admin_page, render_page_admin_oob
tctx = await get_template_context()
if not is_htmx_request():
html = await render_page_admin_page(tctx)
else:
html = await render_page_admin_oob(tctx)
return await make_response(html)
return bp

View File

@@ -15,6 +15,7 @@ from shared.sexp.jinja_bridge import render, load_service_components
from shared.sexp.helpers import (
call_url, get_asset_url, root_header_html,
post_header_html as _post_header_html,
post_admin_header_html,
oob_header_html as _oob_header_html,
search_mobile_html, search_desktop_html,
full_page, oob_page,
@@ -1420,7 +1421,7 @@ async def render_market_admin_page(ctx: dict) -> str:
content = "market admin"
hdr = root_header_html(ctx)
child = _post_header_html(ctx) + _market_header_html(ctx) + _market_admin_header_html(ctx)
child = _post_header_html(ctx) + _market_header_html(ctx) + _market_admin_header_html(ctx, selected="markets")
hdr += render("header-child", inner_html=child)
return full_page(ctx, header_rows_html=hdr, content_html=content)
@@ -1431,21 +1432,37 @@ async def render_market_admin_oob(ctx: dict) -> str:
oobs = _market_header_html(ctx, oob=True)
oobs += _oob_header_html("market-header-child", "market-admin-header-child",
_market_admin_header_html(ctx))
_market_admin_header_html(ctx, selected="markets"))
return oob_page(ctx, oobs_html=oobs, content_html=content)
def _market_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
"""Build market admin header row."""
from quart import url_for
def _market_admin_header_html(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
"""Build market admin header row — delegates to shared helper."""
slug = (ctx.get("post") or {}).get("slug", "")
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
link_href = url_for("market.admin.admin")
return render(
"menu-row",
id="market-admin-row", level=3,
link_href=link_href, link_label="admin", icon="fa fa-cog",
child_id="market-admin-header-child", oob=oob,
)
# ---------------------------------------------------------------------------
# Page admin (/<slug>/admin/) — post-level admin for markets
# ---------------------------------------------------------------------------
async def render_page_admin_page(ctx: dict) -> str:
"""Full page: page-level market admin."""
slug = (ctx.get("post") or {}).get("slug", "")
admin_hdr = post_admin_header_html(ctx, slug, selected="markets")
hdr = root_header_html(ctx)
child = _post_header_html(ctx) + admin_hdr
hdr += render("header-child", inner_html=child)
content = '<div id="main-panel"><div class="p-4 text-stone-500">Market admin</div></div>'
return full_page(ctx, header_rows_html=hdr, content_html=content)
async def render_page_admin_oob(ctx: dict) -> str:
"""OOB response: page-level market admin."""
slug = (ctx.get("post") or {}).get("slug", "")
oobs = post_admin_header_html(ctx, slug, oob=True, selected="markets")
content = '<div id="main-panel"><div class="p-4 text-stone-500">Market admin</div></div>'
return oob_page(ctx, oobs_html=oobs, content_html=content)
# ---------------------------------------------------------------------------

View File

@@ -8,6 +8,8 @@ from __future__ import annotations
from typing import Any
from markupsafe import escape
from .jinja_bridge import render
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
@@ -104,6 +106,62 @@ def post_header_html(ctx: dict, *, oob: bool = False) -> str:
)
def post_admin_header_html(ctx: dict, slug: str, *, oob: bool = False,
selected: str = "", admin_href: str = "") -> str:
"""Shared post admin header row with unified nav across all services.
Shows: calendars | markets | payments | entries | data | edit | settings
All links are external (cross-service). The *selected* item is
highlighted on the nav and shown in white next to the admin label.
"""
# Label: shield icon + "admin" + optional selected sub-page in white
label_html = '<i class="fa fa-shield-halved" aria-hidden="true"></i> admin'
if selected:
label_html += f' <span class="text-white">{escape(selected)}</span>'
# Nav items — all external links to the appropriate service
select_colours = ctx.get("select_colours", "")
base_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded bg-stone-200 text-black p-3")
selected_cls = ("justify-center cursor-pointer flex flex-row items-center"
" gap-2 rounded !bg-stone-500 !text-white p-3")
nav_parts: list[str] = []
items = [
("events_url", f"/{slug}/admin/", "calendars"),
("market_url", f"/{slug}/admin/", "markets"),
("cart_url", f"/{slug}/admin/payments/", "payments"),
("blog_url", f"/{slug}/admin/entries/", "entries"),
("blog_url", f"/{slug}/admin/data/", "data"),
("blog_url", f"/{slug}/admin/edit/", "edit"),
("blog_url", f"/{slug}/admin/settings/", "settings"),
]
for url_key, path, label in items:
url_fn = ctx.get(url_key)
if not callable(url_fn):
continue
href = url_fn(path)
is_sel = label == selected
cls = selected_cls if is_sel else base_cls
aria = ' aria-selected="true"' if is_sel else ""
nav_parts.append(
f'<div class="relative nav-group">'
f'<a href="{escape(href)}"{aria}'
f' class="{cls} {escape(select_colours)}">'
f'{escape(label)}</a></div>'
)
nav_html = "".join(nav_parts)
if not admin_href:
blog_fn = ctx.get("blog_url")
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
return render("menu-row",
id="post-admin-row", level=2,
link_href=admin_href, link_label_html=label_html,
nav_html=nav_html, child_id="post-admin-header-child", oob=oob,
)
def oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
"""Wrap a header row in an OOB swap div with child placeholder."""
return render("oob-header",