Add global + page-scoped market listings with infinite scroll
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
- New all_markets blueprint at / with paginated grid and HTMX infinite scroll - New page_markets blueprint at /<slug>/ for page-scoped market listing - list_marketplaces service method (via shared submodule update) - Updated slug preprocessor to handle both /<slug>/ and /<page_slug>/<market_slug>/ - Removed inline markets_listing() route (replaced by all_markets blueprint) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
70
app.py
70
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: /<slug>/ — markets for a single page
|
||||
app.register_blueprint(
|
||||
register_page_markets(),
|
||||
url_prefix="/<slug>",
|
||||
)
|
||||
|
||||
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
|
||||
app.register_blueprint(
|
||||
register_market_bp(
|
||||
@@ -86,10 +98,14 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<page_slug>/<market_slug>",
|
||||
)
|
||||
|
||||
# --- 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 (/<page_slug>/<market_slug>/)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
0
bp/all_markets/__init__.py
Normal file
0
bp/all_markets/__init__.py
Normal file
74
bp/all_markets/routes.py
Normal file
74
bp/all_markets/routes.py
Normal file
@@ -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
|
||||
0
bp/page_markets/__init__.py
Normal file
0
bp/page_markets/__init__.py
Normal file
65
bp/page_markets/routes.py
Normal file
65
bp/page_markets/routes.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Page-markets blueprint — shows markets for a single page.
|
||||
|
||||
Mounted at /<slug> (page-scoped). Requires g.post_data from hydrate_post.
|
||||
|
||||
Routes:
|
||||
GET /<slug>/ — full page scoped to this page
|
||||
GET /<slug>/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
|
||||
2
shared
2
shared
Submodule shared updated: 30b5a1438b...b16ba34b40
33
templates/_types/all_markets/_card.html
Normal file
33
templates/_types/all_markets/_card.html
Normal file
@@ -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 %}
|
||||
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
|
||||
<div>
|
||||
{% if market_href %}
|
||||
<a href="{{ market_href }}" class="hover:text-emerald-700">
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||
</a>
|
||||
{% else %}
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if market.description %}
|
||||
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-3">
|
||||
{% if page_title %}
|
||||
<a href="{{ market_url('/' ~ page_slug ~ '/') }}"
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
|
||||
{{ page_title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
18
templates/_types/all_markets/_cards.html
Normal file
18
templates/_types/all_markets/_cards.html
Normal file
@@ -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 %}
|
||||
<div
|
||||
id="sentinel-{{ page }}"
|
||||
class="h-4 opacity-0 pointer-events-none"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-trigger="intersect once delay:250ms"
|
||||
hx-swap="outerHTML"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="text-center text-xs text-stone-400">loading...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
12
templates/_types/all_markets/_main_panel.html
Normal file
12
templates/_types/all_markets/_main_panel.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Markets grid #}
|
||||
{% if markets %}
|
||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{% include "_types/all_markets/_cards.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-3 py-12 text-center text-stone-400">
|
||||
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
|
||||
<p class="text-lg">No markets available</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pb-8"></div>
|
||||
7
templates/_types/all_markets/index.html
Normal file
7
templates/_types/all_markets/index.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/all_markets/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
13
templates/_types/page_markets/_card.html
Normal file
13
templates/_types/page_markets/_card.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{# Card for a single market in a page-scoped listing #}
|
||||
{% set market_href = market_url('/' ~ post.slug ~ '/' ~ market.slug ~ '/') %}
|
||||
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
|
||||
<div>
|
||||
<a href="{{ market_href }}" class="hover:text-emerald-700">
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||
</a>
|
||||
|
||||
{% if market.description %}
|
||||
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
18
templates/_types/page_markets/_cards.html
Normal file
18
templates/_types/page_markets/_cards.html
Normal file
@@ -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 %}
|
||||
<div
|
||||
id="sentinel-{{ page }}"
|
||||
class="h-4 opacity-0 pointer-events-none"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-trigger="intersect once delay:250ms"
|
||||
hx-swap="outerHTML"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="text-center text-xs text-stone-400">loading...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
12
templates/_types/page_markets/_main_panel.html
Normal file
12
templates/_types/page_markets/_main_panel.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Markets grid for a single page #}
|
||||
{% if markets %}
|
||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{% include "_types/page_markets/_cards.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-3 py-12 text-center text-stone-400">
|
||||
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
|
||||
<p class="text-lg">No markets for this page</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pb-8"></div>
|
||||
15
templates/_types/page_markets/index.html
Normal file
15
templates/_types/page_markets/index.html
Normal file
@@ -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 %}
|
||||
Reference in New Issue
Block a user