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:
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
0
market/bp/page_admin/__init__.py
Normal file
0
market/bp/page_admin/__init__.py
Normal file
25
market/bp/page_admin/routes.py
Normal file
25
market/bp/page_admin/routes.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user