Compare commits
4 Commits
322ae481ee
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab4b7b3fe | ||
|
|
20d3ff8425 | ||
|
|
cf2e2ba1db | ||
|
|
5518c95237 |
@@ -23,9 +23,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="flex flex-col items-center flex-1">
|
<div class="flex flex-col items-center flex-1">
|
||||||
<div class="flex w-full justify-center md:justify-start">
|
<div class="flex w-full justify-center md:justify-start">
|
||||||
{# Cart mini #}
|
{# Cart mini — rendered via fragment #}
|
||||||
{% from '_types/cart/_mini.html' import mini with context %}
|
{% if cart_mini_html %}
|
||||||
{{mini()}}
|
{{ cart_mini_html | safe }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Site title #}
|
{# Site title #}
|
||||||
<div class="font-bold text-5xl flex-1">
|
<div class="font-bold text-5xl flex-1">
|
||||||
|
|||||||
31
browser/templates/macros/cart_icon.html
Normal file
31
browser/templates/macros/cart_icon.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{# Cart icon/badge — shows logo when empty, cart icon with count when items present #}
|
||||||
|
|
||||||
|
{% macro cart_icon(count=0, oob=False) %}
|
||||||
|
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %}>
|
||||||
|
{% if count == 0 %}
|
||||||
|
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||||
|
<a
|
||||||
|
href="{{ blog_url('/') }}"
|
||||||
|
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ site().logo }}"
|
||||||
|
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a
|
||||||
|
href="{{ cart_url('/') }}"
|
||||||
|
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
|
||||||
|
<span
|
||||||
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||||
|
>
|
||||||
|
{{ count }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
@@ -26,7 +26,17 @@
|
|||||||
<summary class="bg-white/90">
|
<summary class="bg-white/90">
|
||||||
<div class="flex flex-row items-start">
|
<div class="flex flex-row items-start">
|
||||||
<div>
|
<div>
|
||||||
{% include '_types/blog/mobile/_filter/_hamburger.html' %}
|
<div class="md:hidden mx-2 bg-stone-200 rounded">
|
||||||
|
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
|
||||||
|
<i class="fa-solid fa-filter"></i>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24"
|
||||||
|
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
|
||||||
|
<path d="M6 9l6 6 6-6" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="{{id}}"
|
id="{{id}}"
|
||||||
@@ -37,8 +47,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% import '_types/browse/mobile/_filter/search.html' as s %}
|
{% from 'macros/search.html' import search_mobile %}
|
||||||
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
{{ search_mobile(current_local_href, search, search_count, hx_select) }}
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
83
browser/templates/macros/search.html
Normal file
83
browser/templates/macros/search.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{# Shared search input macros for filter UIs #}
|
||||||
|
|
||||||
|
{% macro search_mobile(current_local_href, search, search_count, hx_select) -%}
|
||||||
|
<div
|
||||||
|
id="search-mobile-wrapper"
|
||||||
|
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="search-mobile"
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
aria-label="search"
|
||||||
|
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
|
||||||
|
hx-preserve
|
||||||
|
value="{{ search|default('', true) }}"
|
||||||
|
placeholder="search"
|
||||||
|
hx-trigger="input changed delay:300ms"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
|
||||||
|
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||||
|
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
|
||||||
|
hx-sync="this:replace"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="search-count-mobile"
|
||||||
|
aria-label="search count"
|
||||||
|
{% if not search_count %}
|
||||||
|
class="text-xl text-red-500"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
{% if search %}
|
||||||
|
{{search_count}}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro search_desktop(current_local_href, search, search_count, hx_select) -%}
|
||||||
|
<div
|
||||||
|
id="search-desktop-wrapper"
|
||||||
|
class="flex flex-row gap-2 items-center"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="search-desktop"
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
aria-label="search"
|
||||||
|
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
|
||||||
|
hx-preserve
|
||||||
|
value="{{ search|default('', true) }}"
|
||||||
|
placeholder="search"
|
||||||
|
hx-trigger="input changed delay:300ms"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
|
||||||
|
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||||
|
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
|
||||||
|
hx-sync="this:replace"
|
||||||
|
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="search-count-desktop"
|
||||||
|
aria-label="search count"
|
||||||
|
{% if not search_count %}
|
||||||
|
class="text-xl text-red-500"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
{% if search %}
|
||||||
|
{{search_count}}
|
||||||
|
{% endif %}
|
||||||
|
{{zap_filter}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -15,7 +15,8 @@ _engine = create_async_engine(
|
|||||||
future=True,
|
future=True,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_size=0, # 0 = unlimited (NullPool equivalent for asyncpg)
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
_Session = async_sessionmaker(
|
_Session = async_sessionmaker(
|
||||||
@@ -63,11 +64,17 @@ def register_db(app: Quart):
|
|||||||
# If an exception occurred OR we didn't commit (still in txn), roll back.
|
# If an exception occurred OR we didn't commit (still in txn), roll back.
|
||||||
if hasattr(g, "s"):
|
if hasattr(g, "s"):
|
||||||
if exc is not None or g.s.in_transaction():
|
if exc is not None or g.s.in_transaction():
|
||||||
if hasattr(g, "tx"):
|
if hasattr(g, "tx") and g.tx.is_active:
|
||||||
await g.tx.rollback()
|
try:
|
||||||
|
await g.tx.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
if hasattr(g, "s"):
|
if hasattr(g, "s"):
|
||||||
await g.s.close()
|
try:
|
||||||
|
await g.s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
async def mark_error(e):
|
async def mark_error(e):
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ Each coop app exposes HTML fragments at ``/internal/fragments/{type}``.
|
|||||||
This module provides helpers to fetch and cache those fragments so that
|
This module provides helpers to fetch and cache those fragments so that
|
||||||
consuming apps can compose cross-app UI without shared templates.
|
consuming apps can compose cross-app UI without shared templates.
|
||||||
|
|
||||||
All functions return ``""`` on error (graceful degradation — a missing
|
Failures raise ``FragmentError`` by default so broken fragments are
|
||||||
fragment simply means a section is absent from the page).
|
immediately visible rather than silently missing from the page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -31,6 +31,10 @@ _DEFAULT_TIMEOUT = 2.0
|
|||||||
FRAGMENT_HEADER = "X-Fragment-Request"
|
FRAGMENT_HEADER = "X-Fragment-Request"
|
||||||
|
|
||||||
|
|
||||||
|
class FragmentError(Exception):
|
||||||
|
"""Raised when a fragment fetch fails."""
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> httpx.AsyncClient:
|
def _get_client() -> httpx.AsyncClient:
|
||||||
global _client
|
global _client
|
||||||
if _client is None or _client.is_closed:
|
if _client is None or _client.is_closed:
|
||||||
@@ -55,17 +59,35 @@ def _internal_url(app_name: str) -> str:
|
|||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _is_fragment_request() -> bool:
|
||||||
|
"""True when the current request is itself a fragment fetch."""
|
||||||
|
try:
|
||||||
|
from quart import request as _req
|
||||||
|
return bool(_req.headers.get(FRAGMENT_HEADER))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def fetch_fragment(
|
async def fetch_fragment(
|
||||||
app_name: str,
|
app_name: str,
|
||||||
fragment_type: str,
|
fragment_type: str,
|
||||||
*,
|
*,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fetch an HTML fragment from another app.
|
"""Fetch an HTML fragment from another app.
|
||||||
|
|
||||||
Returns the raw HTML string, or ``""`` on any error.
|
Returns the raw HTML string. When *required* is True (default),
|
||||||
|
raises ``FragmentError`` on network errors or non-200 responses.
|
||||||
|
When *required* is False, returns ``""`` on failure.
|
||||||
|
|
||||||
|
Automatically returns ``""`` when called inside a fragment request
|
||||||
|
to prevent circular dependencies between apps.
|
||||||
"""
|
"""
|
||||||
|
if _is_fragment_request():
|
||||||
|
return ""
|
||||||
|
|
||||||
base = _internal_url(app_name)
|
base = _internal_url(app_name)
|
||||||
url = f"{base}/internal/fragments/{fragment_type}"
|
url = f"{base}/internal/fragments/{fragment_type}"
|
||||||
try:
|
try:
|
||||||
@@ -77,10 +99,20 @@ async def fetch_fragment(
|
|||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.text
|
return resp.text
|
||||||
log.debug("Fragment %s/%s returned %s", app_name, fragment_type, resp.status_code)
|
msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}"
|
||||||
|
if required:
|
||||||
|
log.error(msg)
|
||||||
|
raise FragmentError(msg)
|
||||||
|
log.warning(msg)
|
||||||
return ""
|
return ""
|
||||||
except Exception:
|
except FragmentError:
|
||||||
log.debug("Fragment %s/%s failed", app_name, fragment_type, exc_info=True)
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
msg = f"Fragment {app_name}/{fragment_type} failed: {exc}"
|
||||||
|
if required:
|
||||||
|
log.error(msg)
|
||||||
|
raise FragmentError(msg) from exc
|
||||||
|
log.warning(msg)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -88,15 +120,16 @@ async def fetch_fragments(
|
|||||||
requests: Sequence[tuple[str, str, dict | None]],
|
requests: Sequence[tuple[str, str, dict | None]],
|
||||||
*,
|
*,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Fetch multiple fragments concurrently.
|
"""Fetch multiple fragments concurrently.
|
||||||
|
|
||||||
*requests* is a sequence of ``(app_name, fragment_type, params)`` tuples.
|
*requests* is a sequence of ``(app_name, fragment_type, params)`` tuples.
|
||||||
Returns a list of HTML strings in the same order. Failed fetches
|
Returns a list of HTML strings in the same order. When *required*
|
||||||
produce ``""``.
|
is True, any single failure raises ``FragmentError``.
|
||||||
"""
|
"""
|
||||||
return list(await asyncio.gather(*(
|
return list(await asyncio.gather(*(
|
||||||
fetch_fragment(app, ftype, params=params, timeout=timeout)
|
fetch_fragment(app, ftype, params=params, timeout=timeout, required=required)
|
||||||
for app, ftype, params in requests
|
for app, ftype, params in requests
|
||||||
)))
|
)))
|
||||||
|
|
||||||
@@ -108,11 +141,11 @@ async def fetch_fragment_cached(
|
|||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
ttl: int = 30,
|
ttl: int = 30,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
|
required: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fetch a fragment with a Redis cache layer.
|
"""Fetch a fragment with a Redis cache layer.
|
||||||
|
|
||||||
Cache key: ``frag:{app}:{type}:{sorted_params}``.
|
Cache key: ``frag:{app}:{type}:{sorted_params}``.
|
||||||
Returns ``""`` on error (cache miss + fetch failure).
|
|
||||||
"""
|
"""
|
||||||
# Build a stable cache key
|
# Build a stable cache key
|
||||||
suffix = ""
|
suffix = ""
|
||||||
@@ -133,7 +166,7 @@ async def fetch_fragment_cached(
|
|||||||
|
|
||||||
# Cache miss — fetch from provider
|
# Cache miss — fetch from provider
|
||||||
html = await fetch_fragment(
|
html = await fetch_fragment(
|
||||||
app_name, fragment_type, params=params, timeout=timeout,
|
app_name, fragment_type, params=params, timeout=timeout, required=required,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store in cache (even empty string — avoids hammering a down service)
|
# Store in cache (even empty string — avoids hammering a down service)
|
||||||
|
|||||||
Reference in New Issue
Block a user