diff --git a/app.py b/app.py index b3a8acb..518b2ac 100644 --- a/app.py +++ b/app.py @@ -3,14 +3,14 @@ import path_setup # noqa: F401 # adds shared/ to sys.path from pathlib import Path -from quart import g, abort, render_template, make_response +from quart import g, abort from jinja2 import FileSystemLoader, ChoiceLoader from sqlalchemy import select from shared.infrastructure.factory import create_base_app from shared.config import config -from bp import register_market_bp +from bp import register_market_bp, register_all_markets, register_page_markets async def market_context() -> dict: @@ -77,6 +77,18 @@ def create_app() -> "Quart": app.jinja_loader, ]) + # All markets: / — global view across all pages + app.register_blueprint( + register_all_markets(), + url_prefix="/", + ) + + # Page markets: // — markets for a single page + app.register_blueprint( + register_page_markets(), + url_prefix="/", + ) + # Market blueprint nested under post slug: /// app.register_blueprint( register_market_bp( @@ -86,10 +98,14 @@ def create_app() -> "Quart": url_prefix="//", ) - # --- Auto-inject page_slug and market_slug into url_for() calls --- + # --- Auto-inject slugs into url_for() calls --- @app.url_value_preprocessor def pull_slugs(endpoint, values): if values: + # page_markets blueprint uses "slug" + if "slug" in values: + g.post_slug = values.pop("slug") + # market blueprint uses "page_slug" / "market_slug" if "page_slug" in values: g.post_slug = values.pop("page_slug") if "market_slug" in values: @@ -97,18 +113,22 @@ def create_app() -> "Quart": @app.url_defaults def inject_slugs(endpoint, values): - for attr, param in [("post_slug", "page_slug"), ("market_slug", "market_slug")]: - val = g.get(attr) - if val and param not in values: - if app.url_map.is_endpoint_expecting(endpoint, param): - values[param] = val + slug = g.get("post_slug") + if slug: + for param in ("slug", "page_slug"): + if param not in values and app.url_map.is_endpoint_expecting(endpoint, param): + values[param] = slug + market_slug = g.get("market_slug") + if market_slug and "market_slug" not in values: + if app.url_map.is_endpoint_expecting(endpoint, "market_slug"): + values["market_slug"] = market_slug # --- Load post and market data --- @app.before_request async def hydrate_market(): post_slug = getattr(g, "post_slug", None) market_slug = getattr(g, "market_slug", None) - if not post_slug or not market_slug: + if not post_slug: return # Load post by slug via blog service @@ -129,7 +149,10 @@ def create_app() -> "Quart": }, } - # Load market scoped to post (container pattern) + # Only load market when market_slug is present (///) + if not market_slug: + return + market = ( await g.s.execute( select(MarketPlace).where( @@ -151,33 +174,6 @@ def create_app() -> "Quart": return {} return {**post_data} - # --- Root route: market listing --- - @app.get("/") - async def markets_listing(): - result = await g.s.execute( - select(MarketPlace) - .where(MarketPlace.deleted_at.is_(None), MarketPlace.container_type == "page") - .order_by(MarketPlace.name) - ) - all_markets = result.scalars().all() - - # Resolve page posts via blog service - post_ids = list({m.container_id for m in all_markets}) - posts_by_id = { - p.id: p - for p in await services.blog.get_posts_by_ids(g.s, post_ids) - } - markets = [] - for market in all_markets: - market.page = posts_by_id.get(market.container_id) - markets.append(market) - - html = await render_template( - "_types/market/markets_listing.html", - markets=markets, - ) - return await make_response(html) - return app diff --git a/bp/__init__.py b/bp/__init__.py index 2628e1a..41758ec 100644 --- a/bp/__init__.py +++ b/bp/__init__.py @@ -1,2 +1,4 @@ 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 diff --git a/bp/all_markets/__init__.py b/bp/all_markets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/all_markets/routes.py b/bp/all_markets/routes.py new file mode 100644 index 0000000..0ce086d --- /dev/null +++ b/bp/all_markets/routes.py @@ -0,0 +1,74 @@ +""" +All-markets blueprint — shows markets across ALL pages. + +Mounted at / (root of market app). No slug context. + +Routes: + GET / — full page with first page of markets + GET /all-markets — HTMX fragment for infinite scroll +""" +from __future__ import annotations + +from quart import Blueprint, g, request, render_template, make_response + +from shared.browser.app.utils.htmx import is_htmx_request +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("all_markets", __name__) + + async def _load_markets(page, per_page=20): + """Load all markets + page info for container badges.""" + markets, has_more = await services.market.list_marketplaces( + g.s, page=page, per_page=per_page, + ) + + # Batch-load page info for container_ids + page_info = {} + if markets: + post_ids = list({ + m.container_id for m in markets + if m.container_type == "page" + }) + if post_ids: + posts = await services.blog.get_posts_by_ids(g.s, post_ids) + for p in posts: + page_info[p.id] = {"title": p.title, "slug": p.slug} + + return markets, has_more, page_info + + @bp.get("/") + async def index(): + page = int(request.args.get("page", 1)) + markets, has_more, page_info = await _load_markets(page) + + ctx = dict( + markets=markets, + has_more=has_more, + page_info=page_info, + page=page, + ) + + if is_htmx_request(): + html = await render_template("_types/all_markets/_main_panel.html", **ctx) + else: + html = await render_template("_types/all_markets/index.html", **ctx) + + return await make_response(html, 200) + + @bp.get("/all-markets") + async def markets_fragment(): + page = int(request.args.get("page", 1)) + markets, has_more, page_info = await _load_markets(page) + + html = await render_template( + "_types/all_markets/_cards.html", + markets=markets, + has_more=has_more, + page_info=page_info, + page=page, + ) + return await make_response(html, 200) + + return bp diff --git a/bp/page_markets/__init__.py b/bp/page_markets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bp/page_markets/routes.py b/bp/page_markets/routes.py new file mode 100644 index 0000000..e18a616 --- /dev/null +++ b/bp/page_markets/routes.py @@ -0,0 +1,65 @@ +""" +Page-markets blueprint — shows markets for a single page. + +Mounted at / (page-scoped). Requires g.post_data from hydrate_post. + +Routes: + GET // — full page scoped to this page + GET //page-markets — HTMX fragment for infinite scroll +""" +from __future__ import annotations + +from quart import Blueprint, g, request, render_template, make_response + +from shared.browser.app.utils.htmx import is_htmx_request +from shared.services.registry import services + + +def register() -> Blueprint: + bp = Blueprint("page_markets", __name__) + + async def _load_markets(post_id, page, per_page=20): + """Load markets for this page's container.""" + markets, has_more = await services.market.list_marketplaces( + g.s, "page", post_id, page=page, per_page=per_page, + ) + return markets, has_more + + @bp.get("/") + async def index(): + post = g.post_data["post"] + page = int(request.args.get("page", 1)) + + markets, has_more = await _load_markets(post["id"], page) + + ctx = dict( + markets=markets, + has_more=has_more, + page_info={}, + page=page, + ) + + if is_htmx_request(): + html = await render_template("_types/page_markets/_main_panel.html", **ctx) + else: + html = await render_template("_types/page_markets/index.html", **ctx) + + return await make_response(html, 200) + + @bp.get("/page-markets") + async def markets_fragment(): + post = g.post_data["post"] + page = int(request.args.get("page", 1)) + + markets, has_more = await _load_markets(post["id"], page) + + html = await render_template( + "_types/page_markets/_cards.html", + markets=markets, + has_more=has_more, + page_info={}, + page=page, + ) + return await make_response(html, 200) + + return bp diff --git a/shared b/shared index 30b5a14..b16ba34 160000 --- a/shared +++ b/shared @@ -1 +1 @@ -Subproject commit 30b5a1438be5db6817c1a7ef8d3ee441165fb2dc +Subproject commit b16ba34b40291a71a546130862acc00959c225fd diff --git a/templates/_types/all_markets/_card.html b/templates/_types/all_markets/_card.html new file mode 100644 index 0000000..3680e60 --- /dev/null +++ b/templates/_types/all_markets/_card.html @@ -0,0 +1,33 @@ +{# Card for a single market in the global listing #} +{% set pi = page_info.get(market.container_id, {}) %} +{% set page_slug = pi.get('slug', '') %} +{% set page_title = pi.get('title') %} +{% if page_slug %} + {% set market_href = market_url('/' ~ page_slug ~ '/' ~ market.slug ~ '/') %} +{% else %} + {% set market_href = '' %} +{% endif %} +
+
+ {% if market_href %} + +

{{ market.name }}

+
+ {% else %} +

{{ market.name }}

+ {% endif %} + + {% if market.description %} +

{{ market.description }}

+ {% endif %} +
+ +
+ {% if page_title %} + + {{ page_title }} + + {% endif %} +
+
diff --git a/templates/_types/all_markets/_cards.html b/templates/_types/all_markets/_cards.html new file mode 100644 index 0000000..f3545c5 --- /dev/null +++ b/templates/_types/all_markets/_cards.html @@ -0,0 +1,18 @@ +{% for market in markets %} + {% include "_types/all_markets/_card.html" %} +{% endfor %} +{% if has_more %} + {# Infinite scroll sentinel #} + {% set next_url = url_for('all_markets.markets_fragment', page=page + 1)|host %} + +{% endif %} diff --git a/templates/_types/all_markets/_main_panel.html b/templates/_types/all_markets/_main_panel.html new file mode 100644 index 0000000..3599065 --- /dev/null +++ b/templates/_types/all_markets/_main_panel.html @@ -0,0 +1,12 @@ +{# Markets grid #} +{% if markets %} +
+ {% include "_types/all_markets/_cards.html" %} +
+{% else %} +
+ +

No markets available

+
+{% endif %} +
diff --git a/templates/_types/all_markets/index.html b/templates/_types/all_markets/index.html new file mode 100644 index 0000000..2e7990d --- /dev/null +++ b/templates/_types/all_markets/index.html @@ -0,0 +1,7 @@ +{% extends '_types/root/_index.html' %} + +{% block meta %}{% endblock %} + +{% block content %} + {% include '_types/all_markets/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/page_markets/_card.html b/templates/_types/page_markets/_card.html new file mode 100644 index 0000000..19e31af --- /dev/null +++ b/templates/_types/page_markets/_card.html @@ -0,0 +1,13 @@ +{# Card for a single market in a page-scoped listing #} +{% set market_href = market_url('/' ~ post.slug ~ '/' ~ market.slug ~ '/') %} + diff --git a/templates/_types/page_markets/_cards.html b/templates/_types/page_markets/_cards.html new file mode 100644 index 0000000..bcce864 --- /dev/null +++ b/templates/_types/page_markets/_cards.html @@ -0,0 +1,18 @@ +{% for market in markets %} + {% include "_types/page_markets/_card.html" %} +{% endfor %} +{% if has_more %} + {# Infinite scroll sentinel #} + {% set next_url = url_for('page_markets.markets_fragment', page=page + 1)|host %} + +{% endif %} diff --git a/templates/_types/page_markets/_main_panel.html b/templates/_types/page_markets/_main_panel.html new file mode 100644 index 0000000..c01cfb2 --- /dev/null +++ b/templates/_types/page_markets/_main_panel.html @@ -0,0 +1,12 @@ +{# Markets grid for a single page #} +{% if markets %} +
+ {% include "_types/page_markets/_cards.html" %} +
+{% else %} +
+ +

No markets for this page

+
+{% endif %} +
diff --git a/templates/_types/page_markets/index.html b/templates/_types/page_markets/index.html new file mode 100644 index 0000000..23f99a1 --- /dev/null +++ b/templates/_types/page_markets/index.html @@ -0,0 +1,15 @@ +{% extends '_types/root/_index.html' %} + +{% block meta %}{% endblock %} + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('post-header-child', '_types/post/header/_header.html') %} + {% block post_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block content %} + {% include '_types/page_markets/_main_panel.html' %} +{% endblock %}