From b47ad6224b1e63e8d66596bd88c8da15121bd4af Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 28 Feb 2026 22:01:56 +0000 Subject: [PATCH] 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 //admin/ route + page_admin blueprint, delegates to shared helper (selected=markets). Fixes 404 on page-level admin. Co-Authored-By: Claude Opus 4.6 --- blog/sexp/sexp_components.py | 79 ++------------------------------ cart/sexp/sexp_components.py | 16 ++----- events/sexp/sexp_components.py | 16 ++++++- market/app.py | 8 +++- market/bp/__init__.py | 1 + market/bp/page_admin/__init__.py | 0 market/bp/page_admin/routes.py | 25 ++++++++++ market/sexp/sexp_components.py | 41 ++++++++++++----- shared/sexp/helpers.py | 58 +++++++++++++++++++++++ 9 files changed, 143 insertions(+), 101 deletions(-) create mode 100644 market/bp/page_admin/__init__.py create mode 100644 market/bp/page_admin/routes.py diff --git a/blog/sexp/sexp_components.py b/blog/sexp/sexp_components.py index 5f4119b..5593205 100644 --- a/blog/sexp/sexp_components.py +++ b/blog/sexp/sexp_components.py @@ -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' {escape(selected)}' - - 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) # --------------------------------------------------------------------------- diff --git a/cart/sexp/sexp_components.py b/cart/sexp/sexp_components.py index 4621699..991dab9 100644 --- a/cart/sexp/sexp_components.py +++ b/cart/sexp/sexp_components.py @@ -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 = ' admin' - if selected: - label_html += f' {escape(selected)}' - 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: diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py index 093e9d2..6a2bb2f 100644 --- a/events/sexp/sexp_components.py +++ b/events/sexp/sexp_components.py @@ -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) diff --git a/market/app.py b/market/app.py index 3e196c2..357f24a 100644 --- a/market/app.py +++ b/market/app.py @@ -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="/", ) + # Page admin: //admin/ — post-level admin for markets + app.register_blueprint( + register_page_admin(), + url_prefix="//admin", + ) + # Market blueprint nested under post slug: /// app.register_blueprint( register_market_bp( diff --git a/market/bp/__init__.py b/market/bp/__init__.py index ab2c010..1153c6e 100644 --- a/market/bp/__init__.py +++ b/market/bp/__init__.py @@ -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 diff --git a/market/bp/page_admin/__init__.py b/market/bp/page_admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/market/bp/page_admin/routes.py b/market/bp/page_admin/routes.py new file mode 100644 index 0000000..b1f6b2c --- /dev/null +++ b/market/bp/page_admin/routes.py @@ -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 diff --git a/market/sexp/sexp_components.py b/market/sexp/sexp_components.py index 3131aa7..ae82527 100644 --- a/market/sexp/sexp_components.py +++ b/market/sexp/sexp_components.py @@ -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 (//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 = '
Market admin
' + 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 = '
Market admin
' + return oob_page(ctx, oobs_html=oobs, content_html=content) # --------------------------------------------------------------------------- diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py index 650ca96..44a741b 100644 --- a/shared/sexp/helpers.py +++ b/shared/sexp/helpers.py @@ -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 = ' admin' + if selected: + label_html += f' {escape(selected)}' + + # 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'' + ) + 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",