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 = ''
+ 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 = ''
+ 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",