feat: extract shared infrastructure from shared_lib

Phase 1-3 of decoupling plan:
- Shared DB, models, infrastructure, browser, config, utils
- Event infrastructure (domain_events outbox, bus, processor)
- Structured logging
- Generic container concept (container_type/container_id)
- Alembic migrations for all schema changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-11 12:45:56 +00:00
commit ef806f8fbb
533 changed files with 276497 additions and 0 deletions

1
browser/__init__.py Normal file
View File

@@ -0,0 +1 @@
# suma_browser package

12
browser/app/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
# The monolith has been split into three apps (apps/coop, apps/market, apps/cart).
# This package remains for shared infrastructure modules (middleware, redis_cacher,
# csrf, errors, authz, filters, utils, bp/*).
#
# To run individual apps:
# hypercorn apps.coop.app:app --bind 0.0.0.0:8000
# hypercorn apps.market.app:app --bind 0.0.0.0:8001
# hypercorn apps.cart.app:app --bind 0.0.0.0:8002
#
# Legacy single-process:
# hypercorn suma_browser.app.app:app --bind 0.0.0.0:8000
# (runs the old monolith from app.py, which still works)

152
browser/app/authz.py Normal file
View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from functools import wraps
from typing import Any, Dict, Iterable, Optional
import inspect
from quart import g, abort, redirect, request, current_app
from shared.infrastructure.urls import login_url
def require_rights(*rights: str, any_of: bool = True):
"""
Decorator for routes that require certain user rights.
"""
if not rights:
raise ValueError("require_rights needs at least one right name")
required_set = frozenset(rights)
def decorator(view_func):
@wraps(view_func)
async def wrapper(*args: Any, **kwargs: Any):
# Not logged in → go to login, with ?next=<current path>
user = g.get("user")
if not user:
return redirect(login_url(request.url))
rights_dict = g.get("rights") or {}
if any_of:
allowed = any(rights_dict.get(name) for name in required_set)
else:
allowed = all(rights_dict.get(name) for name in required_set)
if not allowed:
abort(403)
result = view_func(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
# ---- expose access requirements on the wrapper ----
wrapper.__access_requires__ = {
"rights": required_set,
"any_of": any_of,
}
return wrapper
return decorator
def require_login(view_func):
"""
Decorator for routes that require any logged-in user.
"""
@wraps(view_func)
async def wrapper(*args: Any, **kwargs: Any):
user = g.get("user")
if not user:
return redirect(login_url(request.url))
result = view_func(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
return wrapper
def require_admin(view_func=None):
"""
Shortcut for routes that require the 'admin' right.
"""
if view_func is None:
return require_rights("admin")
return require_rights("admin")(view_func)
def require_post_author(view_func):
"""Allow admin or post owner."""
@wraps(view_func)
async def wrapper(*args, **kwargs):
user = g.get("user")
if not user:
return redirect(login_url(request.url))
is_admin = bool((g.get("rights") or {}).get("admin"))
if is_admin:
result = view_func(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
post = getattr(g, "post_data", {}).get("original_post")
if post and post.user_id == user.id:
result = view_func(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
abort(403)
return wrapper
def _get_access_meta(view_func) -> Optional[Dict[str, Any]]:
"""
Walk the wrapper chain looking for __access_requires__ metadata.
"""
func = view_func
seen: set[int] = set()
while func is not None and id(func) not in seen:
seen.add(id(func))
meta = getattr(func, "__access_requires__", None)
if meta is not None:
return meta
func = getattr(func, "__wrapped__", None)
return None
def has_access(endpoint: str) -> bool:
"""
Return True if the current user has access to the given endpoint.
Example:
has_access("settings.home")
has_access("settings.clear_cache_view")
"""
view = current_app.view_functions.get(endpoint)
if view is None:
# Unknown endpoint: be conservative
return False
meta = _get_access_meta(view)
# If the route has no rights metadata, treat it as public:
if meta is None:
return True
required: Iterable[str] = meta["rights"]
any_of: bool = meta["any_of"]
# Must be in a request context; if no user, they don't have access
user = g.get("user")
if not user:
return False
rights_dict = g.get("rights") or {}
if any_of:
return any(rights_dict.get(name) for name in required)
else:
return all(rights_dict.get(name) for name in required)

99
browser/app/csrf.py Normal file
View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import secrets
from typing import Callable, Awaitable, Optional
from quart import (
abort,
current_app,
request,
session as qsession,
)
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
def generate_csrf_token() -> str:
"""
Per-session CSRF token.
In Jinja:
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
"""
token = qsession.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
qsession["csrf_token"] = token
return token
def _is_exempt_endpoint() -> bool:
endpoint = request.endpoint
if not endpoint:
return False
view = current_app.view_functions.get(endpoint)
# Walk decorator stack (__wrapped__) to find csrf_exempt
while view is not None:
if getattr(view, "_csrf_exempt", False):
return True
view = getattr(view, "__wrapped__", None)
return False
async def protect() -> None:
"""
Enforce CSRF on unsafe methods.
Supports:
* Forms: hidden input "csrf_token"
* JSON: "csrf_token" or "csrfToken" field
* HTMX/AJAX: "X-CSRFToken" or "X-CSRF-Token" header
"""
if request.method in SAFE_METHODS:
return
if _is_exempt_endpoint():
return
session_token = qsession.get("csrf_token")
if not session_token:
abort(400, "Missing CSRF session token")
supplied_token: Optional[str] = None
# JSON body
if request.mimetype == "application/json":
data = await request.get_json(silent=True) or {}
supplied_token = data.get("csrf_token") or data.get("csrfToken")
# Form body
if not supplied_token and request.mimetype != "application/json":
form = await request.form
supplied_token = form.get("csrf_token")
# Headers (HTMX / fetch)
if not supplied_token:
supplied_token = (
request.headers.get("X-CSRFToken")
or request.headers.get("X-CSRF-Token")
)
if not supplied_token or supplied_token != session_token:
abort(400, "Invalid CSRF token")
def csrf_exempt(view: Callable[..., Awaitable]) -> Callable[..., Awaitable]:
"""
Mark a view as CSRF-exempt.
from suma_browser.app.csrf import csrf_exempt
@csrf_exempt
@blueprint.post("/hook")
async def webhook():
...
"""
setattr(view, "_csrf_exempt", True)
return view

126
browser/app/errors.py Normal file
View File

@@ -0,0 +1,126 @@
from werkzeug.exceptions import HTTPException
from shared.utils import hx_fragment_request
from quart import (
request,
render_template,
make_response,
current_app
)
from markupsafe import escape
class AppError(ValueError):
"""
Base class for app-level, client-safe errors.
Behaves like ValueError so existing except ValueError: still works.
"""
status_code: int = 400
def __init__(self, message, *, status_code: int | None = None):
# Support a single message or a list/tuple of messages
if isinstance(message, (list, tuple, set)):
self.messages = [str(m) for m in message]
msg = self.messages[0] if self.messages else ""
else:
self.messages = [str(message)]
msg = str(message)
super().__init__(msg)
if status_code is not None:
self.status_code = status_code
def errors(app):
def _info(e):
return {
"exception": e,
"method": request.method,
"url": str(request.url),
"base_url": str(request.base_url),
"root_path": request.root_path,
"path": request.path,
"full_path": request.full_path,
"endpoint": request.endpoint,
"url_rule": str(request.url_rule) if request.url_rule else None,
"headers": {k: v for k, v in request.headers.items()
if k.lower().startswith("x-forwarded") or k in ("Host",)},
}
@app.errorhandler(404)
async def not_found(e):
current_app.logger.warning("404 %s", _info(e))
if hx_fragment_request():
html = await render_template(
"_types/root/exceptions/hx/_.html",
errnum='404'
)
else:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='404',
)
return await make_response(html, 404)
@app.errorhandler(403)
async def not_allowed(e):
current_app.logger.warning("403 %s", _info(e))
if hx_fragment_request():
html = await render_template(
"_types/root/exceptions/hx/_.html",
errnum='403'
)
else:
html = await render_template(
"_types/root/exceptions/_.html",
errnum='403',
)
return await make_response(html, 403)
@app.errorhandler(AppError)
async def app_error(e: AppError):
# App-level, client-safe errors
current_app.logger.info("AppError %s", _info(e))
status = getattr(e, "status_code", 400)
messages = getattr(e, "messages", [str(e)])
if request.headers.get("HX-Request") == "true":
# Build a little styled <ul><li>...</li></ul> snippet
lis = "".join(
f"<li>{escape(m)}</li>"
for m in messages if m
)
html = (
"<ul class='list-disc pl-5 space-y-1 text-sm text-red-600'>"
f"{lis}"
"</ul>"
)
return await make_response(html, status)
# Non-HTMX: show a nicer page with error messages
html = await render_template(
"_types/root/exceptions/app_error.html",
messages=messages,
)
return await make_response(html, status)
@app.errorhandler(Exception)
async def error(e):
current_app.logger.exception("Exception %s", _info(e))
status = 500
if isinstance(e, HTTPException):
status = e.code or 500
if request.headers.get("HX-Request") == "true":
# Generic message for unexpected/untrusted errors
return await make_response(
"Something went wrong. Please try again.",
status,
)
html = await render_template("_types/root/exceptions/error.html")
return await make_response(html, status)

View File

@@ -0,0 +1,17 @@
def register(app):
from .highlight import highlight
app.jinja_env.filters["highlight"] = highlight
from .qs import register as qs
from .url_join import register as url_join
from .combine import register as combine
from .currency import register as currency
from .truncate import register as truncate
from .getattr import register as getattr
qs(app)
url_join(app)
combine(app)
currency(app)
getattr(app)
# truncate(app)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any, Mapping
def _deep_merge(dst: dict, src: Mapping) -> dict:
out = dict(dst)
for k, v in src.items():
if isinstance(v, Mapping) and isinstance(out.get(k), Mapping):
out[k] = _deep_merge(out[k], v) # type: ignore[arg-type]
else:
out[k] = v
return out
def register(app):
@app.template_filter("combine")
def combine_filter(a: Any, b: Any, deep: bool = False, drop_none: bool = False) -> Any:
"""
Jinja filter: merge two dict-like objects.
- Non-dict inputs: returns `a` unchanged.
- If drop_none=True, keys in `b` with value None are ignored.
- If deep=True, nested dicts are merged recursively.
"""
if not isinstance(a, Mapping) or not isinstance(b, Mapping):
return a
b2 = {k: v for k, v in b.items() if not (drop_none and v is None)}
return _deep_merge(a, b2) if deep else {**a, **b2}

View File

@@ -0,0 +1,12 @@
from decimal import Decimal
def register(app):
@app.template_filter("currency")
def currency_filter(value, code="GBP"):
if value is None:
return ""
# ensure decimal-ish
if isinstance(value, float):
value = Decimal(str(value))
symbol = "£" if code == "GBP" else code
return f"{symbol}{value:.2f}"

View File

@@ -0,0 +1,6 @@
def register(app):
@app.template_filter("getattr")
def jinja_getattr(obj, name, default=None):
# Safe getattr: returns default if the attribute is missing
return getattr(obj, name, default)

View File

@@ -0,0 +1,21 @@
# ---------- misc helpers / filters ----------
from markupsafe import Markup, escape
def highlight(text: str, needle: str, cls: str = "bg-yellow-200 rounded") -> Markup:
"""
Wraps case-insensitive matches of `needle` inside <mark class="...">.
Escapes everything safely.
"""
import re
if not text or not needle:
return Markup(escape(text or ""))
pattern = re.compile(re.escape(needle), re.IGNORECASE)
def repl(m: re.Match) -> str:
return f'<mark class="{escape(cls)}">{escape(m.group(0))}</mark>'
esc = escape(text)
result = pattern.sub(lambda m: Markup(repl(m)), esc)
return Markup(result)

13
browser/app/filters/qs.py Normal file
View File

@@ -0,0 +1,13 @@
from typing import Dict
from quart import g
def register(app):
@app.template_filter("qs")
def qs_filter(dict: Dict):
if getattr(g, "makeqs_factory", False):
q= g.makeqs_factory()(
**dict,
)
return q
else:
return ""

View File

@@ -0,0 +1,78 @@
"""
Shared query-string primitives used by blog, market, and order qs modules.
"""
from __future__ import annotations
from urllib.parse import urlencode
# Sentinel meaning "leave value as-is" (used as default arg in makeqs)
KEEP = object()
def _iterify(x):
"""Normalize *x* to a list: None → [], scalar → [scalar], iterable → as-is."""
if x is None:
return []
if isinstance(x, (list, tuple, set)):
return x
return [x]
def _norm(s: str) -> str:
"""Strip + lowercase — used for case-insensitive filter dedup."""
return s.strip().lower()
def make_filter_set(
base: list[str],
add,
remove,
clear_filters: bool,
*,
single_select: bool = False,
) -> list[str]:
"""
Build a deduplicated, sorted filter list.
Parameters
----------
base : list[str]
Current filter values.
add : str | list | None
Value(s) to add.
remove : str | list | None
Value(s) to remove.
clear_filters : bool
If True, start from empty instead of *base*.
single_select : bool
If True, *add* **replaces** the list (blog tags/authors).
If False, *add* is **appended** (market brands/stickers/labels).
"""
add_list = [s for s in _iterify(add) if s is not None]
if single_select:
# Blog-style: adding replaces the entire set
if add_list:
table = {_norm(s): s for s in add_list}
else:
table = {_norm(s): s for s in base if not clear_filters}
else:
# Market-style: adding appends to the existing set
table = {_norm(s): s for s in base if not clear_filters}
for s in add_list:
k = _norm(s)
if k not in table:
table[k] = s
for s in _iterify(remove):
if s is None:
continue
table.pop(_norm(s), None)
return [table[k] for k in sorted(table)]
def build_qs(params: list[tuple[str, str]], *, leading_q: bool = True) -> str:
"""URL-encode *params* and optionally prepend ``?``."""
qs = urlencode(params, doseq=True)
return ("?" + qs) if (qs and leading_q) else qs

View File

@@ -0,0 +1,33 @@
"""
NamedTuple types returned by each blueprint's ``decode()`` function.
"""
from __future__ import annotations
from typing import NamedTuple
class BlogQuery(NamedTuple):
page: int
search: str | None
sort: str | None
selected_tags: tuple[str, ...]
selected_authors: tuple[str, ...]
liked: str | None
view: str | None
drafts: str | None
selected_groups: tuple[str, ...]
class MarketQuery(NamedTuple):
page: int
search: str | None
sort: str | None
selected_brands: tuple[str, ...]
selected_stickers: tuple[str, ...]
selected_labels: tuple[str, ...]
liked: str | None
class OrderQuery(NamedTuple):
page: int
search: str | None

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
def register(app):
@app.template_filter("truncate")
def truncate(text, max_length=100):
"""
Truncate text to max_length characters and add an ellipsis character (…)
if it was longer.
"""
if text is None:
return ""
text = str(text)
if len(text) <= max_length:
return text
# Leave space for the ellipsis itself
if max_length <= 1:
return ""
return text[:max_length - 1] + ""

View File

@@ -0,0 +1,19 @@
from typing import Iterable, Union
from shared.utils import join_url, host_url, _join_url_parts, route_prefix
# --- Register as a Jinja filter (Quart / Flask) ---
def register(app):
@app.template_filter("urljoin")
def urljoin_filter(value: Union[str, Iterable[str]]):
return join_url(value)
@app.template_filter("urlhost")
def urlhost_filter(value: Union[str, Iterable[str]]):
return host_url(value)
@app.template_filter("urlhost_no_slash")
def urlhost_no_slash_filter(value: Union[str, Iterable[str]]):
return host_url(value, True)
@app.template_filter("host")
def host_filter(value: str):
return _join_url_parts([route_prefix(), value])

58
browser/app/middleware.py Normal file
View File

@@ -0,0 +1,58 @@
def register(app):
import json
from typing import Any
def _decode_headers(scope) -> dict[str, str]:
out = {}
for k, v in scope.get("headers", []):
try:
out[k.decode("latin1")] = v.decode("latin1")
except Exception:
out[repr(k)] = repr(v)
return out
def _safe(obj: Any):
# make scope json-serialisable; fall back to repr()
try:
json.dumps(obj)
return obj
except Exception:
return repr(obj)
class ScopeDumpMiddleware:
def __init__(self, app, *, log_bodies: bool = False):
self.app = app
self.log_bodies = log_bodies # keep False; bodies aren't needed for routing
async def __call__(self, scope, receive, send):
if scope["type"] in ("http", "websocket"):
# Build a compact view of keys relevant to routing
scope_view = {
"type": scope.get("type"),
"asgi": scope.get("asgi"),
"http_version": scope.get("http_version"),
"scheme": scope.get("scheme"),
"method": scope.get("method"),
"server": scope.get("server"),
"client": scope.get("client"),
"root_path": scope.get("root_path"),
"path": scope.get("path"),
"raw_path": scope.get("raw_path").decode("latin1") if scope.get("raw_path") else None,
"query_string": scope.get("query_string", b"").decode("latin1"),
"headers": _decode_headers(scope),
}
print("\n=== ASGI SCOPE (routing) ===")
print(json.dumps({_safe(k): _safe(v) for k, v in scope_view.items()}, indent=2))
print("=== END SCOPE ===\n", flush=True)
return await self.app(scope, receive, send)
# wrap LAST so you see what hits Quart
#app.asgi_app = ScopeDumpMiddleware(app.asgi_app)
from hypercorn.middleware import ProxyFixMiddleware
# trust a single proxy hop; use legacy X-Forwarded-* headers
app.asgi_app = ProxyFixMiddleware(app.asgi_app, mode="legacy", trusted_hops=1)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
import os
from typing import Any, Dict, TYPE_CHECKING
import httpx
from quart import current_app
from shared.config import config
if TYPE_CHECKING:
from cart.models.order import Order
SUMUP_BASE_URL = "https://api.sumup.com/v0.1"
def _sumup_settings() -> Dict[str, str]:
cfg = config()
sumup_cfg = cfg.get("sumup", {}) or {}
api_key_env = sumup_cfg.get("api_key_env", "SUMUP_API_KEY")
api_key = os.getenv(api_key_env)
if not api_key:
raise RuntimeError(f"Missing SumUp API key in environment variable {api_key_env}")
merchant_code = sumup_cfg.get("merchant_code")
prefix = sumup_cfg.get("checkout_prefix", "")
if not merchant_code:
raise RuntimeError("Missing 'sumup.merchant_code' in app-config.yaml")
currency = sumup_cfg.get("currency", "GBP")
return {
"api_key": api_key,
"merchant_code": merchant_code,
"currency": currency,
"checkout_reference_prefix": prefix,
}
async def create_checkout(
order: Order,
redirect_url: str,
webhook_url: str | None = None,
description: str | None = None,
) -> Dict[str, Any]:
settings = _sumup_settings()
# Use stored reference if present, otherwise build it
checkout_reference = order.sumup_reference or f"{settings['checkout_reference_prefix']}{order.id}"
payload: Dict[str, Any] = {
"checkout_reference": checkout_reference,
"amount": float(order.total_amount),
"currency": settings["currency"],
"merchant_code": settings["merchant_code"],
"description": description or f"Order {order.id} at {current_app.config.get('APP_TITLE', 'Rose Ash')}",
"return_url": webhook_url or redirect_url,
"redirect_url": redirect_url,
"hosted_checkout": {"enabled": True},
}
headers = {
"Authorization": f"Bearer {settings['api_key']}",
"Content-Type": "application/json",
}
# Optional: log for debugging
current_app.logger.info(
"Creating SumUp checkout %s for Order %s amount %.2f",
checkout_reference,
order.id,
float(order.total_amount),
)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(f"{SUMUP_BASE_URL}/checkouts", json=payload, headers=headers)
if resp.status_code == 409:
# Duplicate checkout — retrieve the existing one by reference
current_app.logger.warning(
"SumUp duplicate checkout for ref %s order %s, fetching existing",
checkout_reference,
order.id,
)
list_resp = await client.get(
f"{SUMUP_BASE_URL}/checkouts",
params={"checkout_reference": checkout_reference},
headers=headers,
)
list_resp.raise_for_status()
items = list_resp.json()
if isinstance(items, list) and items:
return items[0]
if isinstance(items, dict) and items.get("items"):
return items["items"][0]
# Fallback: re-raise original error
resp.raise_for_status()
if resp.status_code >= 400:
current_app.logger.error(
"SumUp checkout error for ref %s order %s: %s",
checkout_reference,
order.id,
resp.text,
)
resp.raise_for_status()
data = resp.json()
return data
async def get_checkout(checkout_id: str) -> Dict[str, Any]:
"""Fetch checkout status/details from SumUp."""
settings = _sumup_settings()
headers = {
"Authorization": f"Bearer {settings['api_key']}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{SUMUP_BASE_URL}/checkouts/{checkout_id}", headers=headers)
resp.raise_for_status()
return resp.json()

346
browser/app/redis_cacher.py Normal file
View File

@@ -0,0 +1,346 @@
from __future__ import annotations
from functools import wraps
from typing import Optional, Literal
import asyncio
from quart import (
Quart,
request,
Response,
g,
current_app,
)
from redis import asyncio as aioredis
Scope = Literal["user", "global", "anon"]
TagScope = Literal["all", "user"] # for clear_cache
# ---------------------------------------------------------------------------
# Redis setup
# ---------------------------------------------------------------------------
def register(app: Quart) -> None:
@app.before_serving
async def setup_redis() -> None:
if app.config["REDIS_URL"] and app.config["REDIS_URL"] != 'no':
app.redis = aioredis.Redis.from_url(
app.config["REDIS_URL"],
encoding="utf-8",
decode_responses=False, # store bytes
)
else:
app.redis = False
@app.after_serving
async def close_redis() -> None:
if app.redis:
await app.redis.close()
# optional: await app.redis.connection_pool.disconnect()
def get_redis():
return current_app.redis
# ---------------------------------------------------------------------------
# Key helpers
# ---------------------------------------------------------------------------
def get_user_id() -> str:
"""
Returns a string id or 'anon'.
Adjust based on your auth system.
"""
user = getattr(g, "user", None)
if user:
return str(user.id)
return "anon"
def make_cache_key(cache_user_id: str) -> str:
"""
Build a cache key for this (user/global/anon) + path + query + HTMX status.
HTMX requests and normal requests get different cache keys because they
return different content (partials vs full pages).
Keys are namespaced by app name (from CACHE_APP_PREFIX) to avoid
collisions between apps that may share the same paths.
"""
app_prefix = current_app.config.get("CACHE_APP_PREFIX", "app")
path = request.path
qs = request.query_string.decode() if request.query_string else ""
# Check if this is an HTMX request
is_htmx = request.headers.get("HX-Request", "").lower() == "true"
htmx_suffix = ":htmx" if is_htmx else ""
if qs:
return f"cache:{app_prefix}:page:{cache_user_id}:{path}?{qs}{htmx_suffix}"
else:
return f"cache:{app_prefix}:page:{cache_user_id}:{path}{htmx_suffix}"
def user_set_key(user_id: str) -> str:
"""
Redis set that tracks all cache keys for a given user id.
Only used when scope='user'.
"""
return f"cache:user:{user_id}"
def tag_set_key(tag: str) -> str:
"""
Redis set that tracks all cache keys associated with a tag
(across all scopes/users).
"""
return f"cache:tag:{tag}"
# ---------------------------------------------------------------------------
# Invalidation helpers
# ---------------------------------------------------------------------------
async def invalidate_user_cache(user_id: str) -> None:
"""
Delete all cached pages for a specific user (scope='user' caches).
"""
r = get_redis()
if r:
s_key = user_set_key(user_id)
keys = await r.smembers(s_key) # set of bytes
if keys:
await r.delete(*keys)
await r.delete(s_key)
async def invalidate_tag_cache(tag: str) -> None:
"""
Delete all cached pages associated with this tag, for all users/scopes.
"""
r = get_redis()
if r:
t_key = tag_set_key(tag)
keys = await r.smembers(t_key) # set of bytes
if keys:
await r.delete(*keys)
await r.delete(t_key)
async def invalidate_tag_cache_for_user(tag: str, cache_uid: str) -> None:
r = get_redis()
if not r:
return
t_key = tag_set_key(tag)
keys = await r.smembers(t_key) # set of bytes
if not keys:
return
prefix = f"cache:page:{cache_uid}:".encode("utf-8")
# Filter keys belonging to this cache_uid only
to_delete = [k for k in keys if k.startswith(prefix)]
if not to_delete:
return
# Delete those page entries
await r.delete(*to_delete)
# Remove them from the tag set (leave other users' keys intact)
await r.srem(t_key, *to_delete)
async def invalidate_tag_cache_for_current_user(tag: str) -> None:
"""
Convenience helper: delete tag cache for the current user_id (scope='user').
"""
uid = get_user_id()
await invalidate_tag_cache_for_user(tag, uid)
# ---------------------------------------------------------------------------
# Cache decorator for GET
# ---------------------------------------------------------------------------
def cache_page(
ttl: int = 0,
tag: Optional[str] = None,
scope: Scope = "user",
):
"""
Cache GET responses in Redis.
ttl:
Seconds to keep the cache. 0 = no expiry.
tag:
Optional tag name used for bulk invalidation via invalidate_tag_cache().
scope:
"user" → cache per-user (includes 'anon'), tracked in cache:user:{id}
"global" → single cache shared by everyone (no per-user tracking)
"anon" → cache only for anonymous users; logged-in users bypass cache
"""
def decorator(view):
@wraps(view)
async def wrapper(*args, **kwargs):
r = get_redis()
if not r or request.method != "GET":
return await view(*args, **kwargs)
uid = get_user_id()
# Decide who the cache key is keyed on
if scope == "global":
cache_uid = "global"
elif scope == "anon":
# Only cache for anonymous users
if uid != "anon":
return await view(*args, **kwargs)
cache_uid = "anon"
else: # scope == "user"
cache_uid = uid
key = make_cache_key(cache_uid)
cached = await r.hgetall(key)
if cached:
body = cached[b"body"]
status = int(cached[b"status"].decode())
content_type = cached.get(b"content_type", b"text/html").decode()
return Response(body, status=status, content_type=content_type)
# Not cached, call the view
resp = await view(*args, **kwargs)
# Normalise: if the view returned a string/bytes, wrap it
if not isinstance(resp, Response):
resp = Response(resp, content_type="text/html")
# Only cache successful responses
if resp.status_code == 200:
body = await resp.get_data() # bytes
pipe = r.pipeline()
pipe.hset(
key,
mapping={
"body": body,
"status": str(resp.status_code),
"content_type": resp.content_type or "text/html",
},
)
if ttl:
pipe.expire(key, ttl)
# Track per-user keys only when scope='user'
if scope == "user":
pipe.sadd(user_set_key(cache_uid), key)
# Track per-tag keys (all scopes)
if tag:
pipe.sadd(tag_set_key(tag), key)
await pipe.execute()
resp.set_data(body)
return resp
return wrapper
return decorator
# ---------------------------------------------------------------------------
# Clear cache decorator for POST (or any method)
# ---------------------------------------------------------------------------
def clear_cache(
*,
tag: Optional[str] = None,
tag_scope: TagScope = "all",
clear_user: bool = False,
):
"""
Decorator for routes that should clear cache after they run.
Use on POST/PUT/PATCH/DELETE handlers.
Params:
tag:
If set, will clear caches for this tag.
tag_scope:
"all" → invalidate_tag_cache(tag) (all users/scopes)
"user" → invalidate_tag_cache_for_current_user(tag)
clear_user:
If True, also run invalidate_user_cache(current_user_id).
Typical usage:
@bp.post("/posts/<slug>/edit")
@clear_cache(tag="post.post_detail", tag_scope="all")
async def edit_post(slug):
...
@bp.post("/prefs")
@clear_cache(tag="dashboard", tag_scope="user", clear_user=True)
async def update_prefs():
...
"""
def decorator(view):
@wraps(view)
async def wrapper(*args, **kwargs):
# Run the view first
resp = await view(*args, **kwargs)
if get_redis():
# Only clear cache if the view succeeded (2xx)
status = getattr(resp, "status_code", None)
if status is None:
# Non-Response return (string, dict) -> treat as success
success = True
else:
success = 200 <= status < 300
if not success:
return resp
# Perform invalidations
tasks = []
if clear_user:
uid = get_user_id()
tasks.append(invalidate_user_cache(uid))
if tag:
if tag_scope == "all":
tasks.append(invalidate_tag_cache(tag))
else: # tag_scope == "user"
tasks.append(invalidate_tag_cache_for_current_user(tag))
if tasks:
# Run them concurrently
await asyncio.gather(*tasks)
return resp
return wrapper
return decorator
async def clear_all_cache(prefix: str = "cache:") -> None:
r = get_redis()
if not r:
return
cursor = 0
pattern = f"{prefix}*"
while True:
cursor, keys = await r.scan(cursor=cursor, match=pattern, count=500)
if keys:
await r.delete(*keys)
if cursor == 0:
break

View File

@@ -0,0 +1,12 @@
from .parse import (
parse_time,
parse_cost,
parse_dt
)
from .utils import (
current_route_relative_path,
current_url_without_page,
vary,
)
from .utc import utcnow

46
browser/app/utils/htmx.py Normal file
View File

@@ -0,0 +1,46 @@
"""HTMX utilities for detecting and handling HTMX requests."""
from quart import request
def is_htmx_request() -> bool:
"""
Check if the current request is an HTMX request.
Returns:
bool: True if HX-Request header is present and true
"""
return request.headers.get("HX-Request", "").lower() == "true"
def get_htmx_target() -> str | None:
"""
Get the target element ID from HTMX request headers.
Returns:
str | None: Target element ID or None
"""
return request.headers.get("HX-Target")
def get_htmx_trigger() -> str | None:
"""
Get the trigger element ID from HTMX request headers.
Returns:
str | None: Trigger element ID or None
"""
return request.headers.get("HX-Trigger")
def should_return_fragment() -> bool:
"""
Determine if we should return a fragment vs full page.
For HTMX requests, return fragment.
For normal requests, return full page.
Returns:
bool: True if fragment should be returned
"""
return is_htmx_request()

View File

@@ -0,0 +1,36 @@
from datetime import datetime, timezone
def parse_time(val: str | None):
if not val:
return None
try:
h,m = val.split(':', 1)
from datetime import time
return time(int(h), int(m))
except Exception:
return None
def parse_cost(val: str | None):
if not val:
return None
try:
return float(val)
except Exception:
return None
if not val:
return None
dt = datetime.fromisoformat(val)
# make TZ-aware (assume local if naive; convert to UTC)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def parse_dt(val: str | None) -> datetime | None:
if not val:
return None
dt = datetime.fromisoformat(val)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt

6
browser/app/utils/utc.py Normal file
View File

@@ -0,0 +1,6 @@
from datetime import datetime, timezone
def utcnow() -> datetime:
return datetime.now(timezone.utc)

View File

@@ -0,0 +1,51 @@
from quart import (
Response,
request,
g,
)
from shared.utils import host_url
from urllib.parse import urlencode
def current_route_relative_path() -> str:
"""
Returns the current request path relative to the app's mount point (script_root).
"""
(request.script_root or "").rstrip("/")
path = request.path # excludes query string
if g.root and path.startswith(f"/{g.root}"):
rel = path[len(g.root+1):]
return rel if rel.startswith("/") else "/" + rel
return path # app at /
def current_url_without_page() -> str:
"""
Build current URL (host+path+qs) but with ?page= removed.
Used for Hx-Push-Url.
"""
base = host_url(current_route_relative_path())
params = request.args.to_dict(flat=False) # keep multivals
params.pop("page", None)
qs = urlencode(params, doseq=True)
return f"{base}?{qs}" if qs else base
def vary(resp: Response) -> Response:
"""
Ensure caches/CDNs vary on HX headers so htmx/non-htmx versions don't get mixed.
"""
v = resp.headers.get("Vary", "")
parts = [p.strip() for p in v.split(",") if p.strip()]
for h in ("HX-Request", "X-Origin"):
if h not in parts:
parts.append(h)
if parts:
resp.headers["Vary"] = ", ".join(parts)
return resp

View File

@@ -0,0 +1,33 @@
{% extends oob.oob_extends %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header(
oob.parent_id,
oob.child_id,
oob.header,
)}}
{% from oob.parent_header import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{# Mobile menu - from market/index.html _main_mobile_menu block #}
{% set mobile_nav %}
{% include oob.nav %}
{% endset %}
{{ mobile_menu(mobile_nav) }}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,49 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">
{% if error %}
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
{# Account header #}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
{% if g.user %}
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
{% if g.user.name %}
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
{% endif %}
{% endif %}
</div>
<form action="{{ url_for('auth.logout')|host }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
>
<i class="fa-solid fa-right-from-bracket text-xs"></i>
Sign out
</button>
</form>
</div>
{# Labels #}
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
{% if labels %}
<div>
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
<div class="flex flex-wrap gap-2">
{% for label in labels %}
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
{{ label.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% call links.link(url_for('auth.newsletters'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
<div class="relative nav-group">
<a href="{{ cart_url('/orders/') }}" class="{{styles.nav_button}}">
orders
</a>
</div>

View File

@@ -0,0 +1,17 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=un.newsletter_id) }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
></span>
</button>
</div>

View File

@@ -0,0 +1,46 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>
{% if newsletter_list %}
<div class="divide-y divide-stone-100">
{% for item in newsletter_list %}
<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ item.newsletter.name }}</p>
{% if item.newsletter.description %}
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
{% endif %}
</div>
<div class="ml-4 flex-shrink-0">
{% if item.un %}
{% with un=item.un %}
{% include "_types/auth/_newsletter_toggle.html" %}
{% endwith %}
{% else %}
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ url_for('auth.toggle_newsletter', newsletter_id=item.newsletter.id) }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
role="switch"
aria-checked="false"
>
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No newsletters available.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,29 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/auth/_nav.html' %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
If an account exists for
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
youll receive a link to sign in. It expires in 15 minutes.
</p>
{% if email_error %}
<div
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
role="alert"
>
<span class="font-medium">Heads up:</span>
<span>{{ email_error }}</span>
</div>
{% endif %}
<p class="mt-6 text-sm">
<a
href="{{ url_for('auth.login_form')|host }}"
class="text-stone-600 dark:text-stone-300 hover:underline"
>
← Back
</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(url_for('auth.account'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include "_types/auth/_nav.html" %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends "_types/root/_index.html" %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include "_types/auth/_nav.html" %}
{% endblock %}
{% block content %}
{% include '_types/auth/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends oob.extends %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row(oob.child_id, oob.header) %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include oob.nav %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Enter your email and well email you a one-time sign-in link.
</p>
{% if error %}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
<form
method="post" action="{{ url_for('auth.start_login')|host }}"
class="mt-6 space-y-5"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Email
</label>
<input
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
autocomplete="email"
inputmode="email"
>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
>
Send link
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{# New Post + Drafts toggle — shown in aside (desktop + mobile) #}
<div class="flex flex-wrap gap-2 px-4 py-3">
{% if has_access('blog.new_post') %}
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="New Post"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
{% endif %}
{% if g.user and (draft_count or drafts) %}
{% if drafts %}
{% set drafts_off_href = (current_local_href ~ {'drafts': None}|qs)|host %}
<a
href="{{ drafts_off_href }}"
hx-get="{{ drafts_off_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
title="Hide Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% else %}
{% set drafts_on_href = (current_local_href ~ {'drafts': '1'}|qs)|host %}
<a
href="{{ drafts_on_href }}"
hx-get="{{ drafts_on_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
title="Show Drafts"
>
<i class="fa fa-file-text-o mr-1"></i> Drafts
<span class="inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1">{{ draft_count }}</span>
</a>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,110 @@
{% import 'macros/stickers.html' as stick %}
<article class="border-b pb-6 last:border-b-0 relative">
{# ❤️ like button - OUTSIDE the link, aligned with image top #}
{% if g.user %}
<div class="absolute top-20 right-2 z-10 text-6xl md:text-4xl">
{% set slug = post.slug %}
{% set liked = post.is_liked or False %}
{% set like_url = url_for('blog.post.like_toggle', slug=slug)|host %}
{% set item_type = 'post' %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
<header class="mb-2 text-center">
<h2 class="text-4xl font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-2 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</header>
{% if post.feature_image %}
<div class="mb-4">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full object-cover"
>
</div>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.custom_excerpt }}
</p>
{% else %}
{% if post.excerpt %}
<p class="text-stone-700 text-lg leading-relaxed text-center">
{{ post.excerpt }}
</p>
{% endif %}
{% endif %}
</a>
{# Associated Entries - Scrollable list #}
{% if post.associated_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in post.associated_entries %}
{% set _entry_path = '/' + post.slug + '/calendars/' + entry.calendar.slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{% endif %}
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,19 @@
<div class="flex flex-row justify-center gap-3">
{% if post.tags %}
<div class="mt-4 flex items-center gap-2">
<div>in</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/tags.html' %}
</ul>
</div>
{% endif %}
<div></div>
{% if post.authors %}
<div class="mt-4 flex items-center gap-2">
<div>by</div>
<ul class="flex flex-wrap gap-2 text-sm">
{% include '_types/blog/_card/authors.html' %}
</ul>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,21 @@
{% macro author(author) %}
{% if author %}
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6"></div>
{# optional fallback circle with first letter
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div> #}
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ author.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,32 @@
{# --- AUTHORS LIST STARTS HERE --- #}
{% if post.authors and post.authors|length %}
{% for a in post.authors %}
{% for author in authors if author.slug==a.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_author': author.slug }|qs|host}}"
>
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- AUTHOR LIST ENDS HERE --- #}

View File

@@ -0,0 +1,19 @@
{% macro tag(tag) %}
{% if tag %}
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ tag.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,22 @@
{% macro tag_group(group) %}
{% if group %}
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,17 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# --- TAG LIST STARTS HERE --- #}
{% if post.tags and post.tags|length %}
{% for t in post.tags %}
{% for tag in tags if tag.slug==t.slug %}
<li>
<a
class="flex items-center gap-1"
href="{{ { 'clear_filters': True, 'add_tag': tag.slug }|qs|host}}"
>
{{dotag.tag(tag)}}
</a>
</li>
{% endfor %}
{% endfor %}
{% endif %}
{# --- TAG LIST ENDS HERE --- #}

View File

@@ -0,0 +1,59 @@
<article class="relative">
{% set _href=url_for('blog.post.post_detail', slug=post.slug)|host %}
<a
href="{{ _href }}"
hx-get="{{ _href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if _active else 'false' }}"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
>
{% if post.feature_image %}
<div>
<img
src="{{ post.feature_image }}"
alt=""
class="w-full aspect-video object-cover"
>
</div>
{% endif %}
<div class="p-3 text-center">
<h2 class="text-lg font-bold text-stone-900">
{{ post.title }}
</h2>
{% if post.status == "draft" %}
<div class="flex justify-center gap-1 mt-1">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800">Draft</span>
{% if post.publish_requested %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>
{% endif %}
</div>
{% if post.updated_at %}
<p class="text-sm text-stone-500">
Updated: {{ post.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% elif post.published_at %}
<p class="text-sm text-stone-500">
Published: {{ post.published_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
{% if post.custom_excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.custom_excerpt }}
</p>
{% elif post.excerpt %}
<p class="text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1">
{{ post.excerpt }}
</p>
{% endif %}
</div>
</a>
{% include '_types/blog/_card/at_bar.html' %}
</article>

View File

@@ -0,0 +1,111 @@
{% for post in posts %}
{% if view == 'tile' %}
{% include "_types/blog/_card_tile.html" %}
{% else %}
{% include "_types/blog/_card.html" %}
{% endif %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -0,0 +1,48 @@
{# View toggle bar - desktop only #}
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('blog_view') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('blog_view','tile') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</a>
</div>
{# Cards container - list or grid based on view #}
{% if view == 'tile' %}
<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/blog/_cards.html" %}
</div>
{% else %}
<div class="max-w-full px-3 py-3 space-y-3">
{% include "_types/blog/_cards.html" %}
</div>
{% endif %}
<div class="pb-8"></div>

View File

@@ -0,0 +1,40 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob_.html' import root_header with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'blog-header-child', '_types/blog/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{# Filter container - blog doesn't have child_summary but still needs this element #}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{# Aside with filters #}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/root/_nav.html' %}
{% include '_types/root/_nav_panel.html' %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-edit-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.edit', id=group.id), 'pencil', group.name, select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,79 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
{# --- Edit group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.save', id=group.id) }}"
class="border rounded p-4 bg-white space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Name</label>
<input
type="text" name="name" value="{{ group.name }}" required
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-xs font-medium text-stone-600 mb-1">Colour</label>
<input
type="text" name="colour" value="{{ group.colour or '' }}" placeholder="#hex"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
<div class="w-24">
<label class="block text-xs font-medium text-stone-600 mb-1">Order</label>
<input
type="number" name="sort_order" value="{{ group.sort_order }}"
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Feature Image URL</label>
<input
type="text" name="feature_image" value="{{ group.feature_image or '' }}"
placeholder="https://..."
class="w-full border rounded px-3 py-2 text-sm"
>
</div>
</div>
{# --- Tag checkboxes --- #}
<div>
<label class="block text-xs font-medium text-stone-600 mb-2">Assign Tags</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2">
{% for tag in all_tags %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer">
<input
type="checkbox" name="tag_ids" value="{{ tag.id }}"
{% if tag.id in assigned_tag_ids %}checked{% endif %}
class="rounded border-stone-300"
>
{% if tag.feature_image %}
<img src="{{ tag.feature_image }}" alt="" class="h-4 w-4 rounded-full object-cover">
{% endif %}
<span>{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="flex gap-3">
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Save
</button>
</div>
</form>
{# --- Delete form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.delete_group', id=group.id) }}"
class="border-t pt-4"
onsubmit="return confirm('Delete this tag group? Tags will not be deleted.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="border rounded px-4 py-2 bg-red-600 text-white text-sm">
Delete Group
</button>
</form>
</div>

View File

@@ -0,0 +1,17 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('tag-groups-header-child', 'tag-groups-edit-child', '_types/blog/admin/tag_groups/_edit_header.html')}}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='tag-groups-row', oob=oob) %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{ admin_nav_item(url_for('blog.tag_groups_admin.index'), 'tags', 'Tag Groups', select_colours, aclass='') }}
{% call links.desktop_nav() %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,73 @@
<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">
{# --- Create new group form --- #}
<form method="post" action="{{ url_for('blog.tag_groups_admin.create') }}" class="border rounded p-4 bg-white space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h3 class="text-sm font-semibold text-stone-700">New Group</h3>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="text" name="name" placeholder="Group name" required
class="flex-1 border rounded px-3 py-2 text-sm"
>
<input
type="text" name="colour" placeholder="#colour"
class="w-28 border rounded px-3 py-2 text-sm"
>
<input
type="number" name="sort_order" placeholder="Order" value="0"
class="w-20 border rounded px-3 py-2 text-sm"
>
</div>
<input
type="text" name="feature_image" placeholder="Image URL (optional)"
class="w-full border rounded px-3 py-2 text-sm"
>
<button type="submit" class="border rounded px-4 py-2 bg-stone-800 text-white text-sm">
Create
</button>
</form>
{# --- Existing groups list --- #}
{% if groups %}
<ul class="space-y-2">
{% for group in groups %}
<li class="border rounded p-3 bg-white flex items-center gap-3">
{% if group.feature_image %}
<img src="{{ group.feature_image }}" alt="{{ group.name }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">
{% else %}
<div class="h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}">
{{ group.name[:1] }}
</div>
{% endif %}
<div class="flex-1">
<a href="{{ url_for('blog.tag_groups_admin.edit', id=group.id) }}"
class="font-medium text-stone-800 hover:underline">
{{ group.name }}
</a>
<span class="text-xs text-stone-500 ml-2">{{ group.slug }}</span>
</div>
<span class="text-xs text-stone-500">order: {{ group.sort_order }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-stone-500 text-sm">No tag groups yet.</p>
{% endif %}
{# --- Unassigned tags --- #}
{% if unassigned_tags %}
<div class="border-t pt-4">
<h3 class="text-sm font-semibold text-stone-700 mb-2">Unassigned Tags ({{ unassigned_tags|length }})</h3>
<div class="flex flex-wrap gap-2">
{% for tag in unassigned_tags %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded">
{{ tag.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,16 @@
{% extends 'oob_elements.html' %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-settings-header-child', 'tag-groups-header-child', '_types/blog/admin/tag_groups/_header.html')}}
{% from '_types/root/settings/header/_header.html' import header_row with context %}
{{header_row(oob=True)}}
{% endblock %}
{% block mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends '_types/blog/admin/tag_groups/index.html' %}
{% block tag_groups_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_edit_header.html' import header_row with context %}
{{ header_row() }}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_edit_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends '_types/root/settings/index.html' %}
{% block root_settings_header_child %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header() %}
{% from '_types/blog/admin/tag_groups/_header.html' import header_row with context %}
{{ header_row() }}
<div id="tag-groups-header-child">
{% block tag_groups_header_child %}
{% endblock %}
</div>
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog/admin/tag_groups/_main_panel.html' %}
{% endblock %}
{% block _main_mobile_menu %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% include '_types/blog/_action_buttons.html' %}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
</div>

View File

@@ -0,0 +1,62 @@
{% import '_types/blog/_card/author.html' as doauthor %}
{# Author filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_authors | length == 0) %}
{% set href =
{
'remove_author': selected_authors,
}|qs
|host %}
<a
class="px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any author
</a>
</li>
{% for author in authors %}
<li>
{% set is_on = (selected_authors and (author.slug in selected_authors)) %}
{% set qs = {"remove_author": author.slug, "page":None}|qs if is_on
else {"add_author": author.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{doauthor.author(author)}}
{% if False and author.bio %}
<span class="inline-block flex-1 bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{% if author.bio|length > 50 %}
{{ author.bio[:50] ~ "…" }}
{% else %}
{{ author.bio }}
{% endif %}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,70 @@
{# Tag group filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_groups | length == 0 and selected_tags | length == 0) %}
{% set href =
{
'remove_group': selected_groups,
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Topic
</a>
</li>
{% for group in tag_groups %}
{% if group.post_count > 0 or (selected_groups and group.slug in selected_groups) %}
<li>
{% set is_on = (selected_groups and (group.slug in selected_groups)) %}
{% set qs = {"remove_group": group.slug, "page":None}|qs if is_on
else {"add_group": group.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200">
{{ group.name }}
</span>
<span class="flex-1"></span>
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.post_count }}
</span>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,59 @@
{% import '_types/blog/_card/tag.html' as dotag %}
{# Tag filter bar #}
<nav class="max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm">
<ul class="divide-y flex flex-col gap-3">
<li>
{% set is_on = (selected_tags | length == 0) %}
{% set href =
{
'remove_tag': selected_tags,
}|qs|host %}
<a
class="px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
Any Tag
</a>
</li>
{% for tag in tags %}
<li>
{% set is_on = (selected_tags and (tag.slug in selected_tags)) %}
{% set qs = {"remove_tag": tag.slug, "page":None}|qs if is_on
else {"add_tag": tag.slug, "page":None}|qs %}
{% set href = qs|host %}
<a
class="flex items-center gap-2 px-3 py-1 rounded border {% if is_on %}bg-stone-900 text-white border-stone-900{% else %}bg-white text-stone-600 border-stone-300 hover:bg-stone-50{% endif %}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{dotag.tag(tag)}}
{% if False and tag.description %}
<span class="flex-1 inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.description }}
</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.published_post_count }}
</span>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='blog-row', oob=oob) %}
<div></div>
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,37 @@
{% extends '_types/root/_index.html' %}
{% block meta %}
{{ super() }}
<script>
(function() {
var p = new URLSearchParams(window.location.search);
if (!p.has('view')
&& window.matchMedia('(min-width: 768px)').matches
&& localStorage.getItem('blog_view') === 'tile') {
p.set('view', 'tile');
window.location.replace(window.location.pathname + '?' + p.toString());
}
})();
</script>
{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% block root_blog_header %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block aside %}
{% include "_types/blog/desktop/menu.html" %}
{% endblock %}
{% block filter %}
{% include "_types/blog/mobile/_filter/summary.html" %}
{% endblock %}
{% block content %}
{% include '_types/blog/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,13 @@
<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>

View File

@@ -0,0 +1,14 @@
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
{% include '_types/blog/mobile/_filter/summary/tag_groups.html' %}
{% include '_types/blog/mobile/_filter/summary/authors.html' %}
{% endcall %}
{% include '_types/blog/_action_buttons.html' %}
<div id="filter-details-mobile" style="display:contents">
{% include '_types/blog/desktop/menu/tag_groups.html' %}
{% include '_types/blog/desktop/menu/authors.html' %}
</div>
{% endcall %}

View File

@@ -0,0 +1,31 @@
{% if selected_authors and selected_authors|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_authors %}
{% for author in authors %}
{% if st == author.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if author.profile_image %}
<img
src="{{ author.profile_image }}"
alt="{{ author.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ author.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ author.name }}
</span>
<span>
{{author.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,33 @@
{% if selected_groups and selected_groups|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for sg in selected_groups %}
{% for group in tag_groups %}
{% if sg == group.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if group.feature_image %}
<img
src="{{ group.feature_image }}"
alt="{{ group.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
<div
class="h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
style="{% if group.colour %}background-color: {{ group.colour }}; color: white;{% else %}background-color: #e7e5e4; color: #57534e;{% endif %}"
>
{{ group.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ group.name }}
</span>
<span>
{{group.post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,31 @@
{% if selected_tags and selected_tags|length %}
<ul class="relative inline-flex flex-col gap-2">
{% for st in selected_tags %}
{% for tag in tags %}
{% if st == tag.slug %}
<li role="listitem" class="flex flex-row items-center gap-1 pb-1">
{% if tag.feature_image %}
<img
src="{{ tag.feature_image }}"
alt="{{ tag.name }}"
class="h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% else %}
{# optional fallback circle with first letter #}
<div class="h-6 w-6 rounded-full bg-stone-200 text-stone-600 text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0">
{{ tag.name[:1] }}
</div>
{% endif %}
<span class="inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200">
{{ tag.name }}
</span>
<span>
{{tag.published_post_count}}
</span>
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}

View File

@@ -0,0 +1,22 @@
{% extends '_types/root/_index.html' %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[50vh] p-8 text-center">
<div class="text-6xl mb-4">📝</div>
<h1 class="text-2xl font-bold text-stone-800 mb-2">Post Not Found</h1>
<p class="text-stone-600 mb-6">
The post "{{ slug }}" could not be found.
</p>
<a
href="{{ url_for('blog.home')|host }}"
hx-get="{{ url_for('blog.home')|host }}"
hx-target="#main-panel"
hx-select="{{ hx_select }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors"
>
← Back to Blog
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
<div class="p-4 space-y-4 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-stone-800">Drafts</h2>
{% set new_href = url_for('blog.new_post')|host %}
<a
href="{{ new_href }}"
hx-get="{{ new_href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
>
<i class="fa fa-plus mr-1"></i> New Post
</a>
</div>
{% if drafts %}
<div class="space-y-3">
{% for draft in drafts %}
{% set edit_href = url_for('blog.post.admin.edit', slug=draft.slug)|host %}
<a
href="{{ edit_href }}"
hx-boost="false"
class="block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-stone-900 truncate">
{{ draft.title or "Untitled" }}
</h3>
{% if draft.excerpt %}
<p class="text-stone-600 text-sm mt-1 line-clamp-2">
{{ draft.excerpt }}
</p>
{% endif %}
{% if draft.updated_at %}
<p class="text-xs text-stone-400 mt-2">
Updated: {{ draft.updated_at.strftime("%-d %b %Y at %H:%M") }}
</p>
{% endif %}
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 flex-shrink-0">
Draft
</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-stone-500 text-center py-8">No drafts yet.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,12 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_drafts/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,259 @@
{# ── Error banner ── #}
{% if save_error %}
<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">
<strong>Save failed:</strong> {{ save_error }}
</div>
{% endif %}
<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" id="lexical-json-input" name="lexical" value="">
<input type="hidden" id="feature-image-input" name="feature_image" value="">
<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">
{# ── Feature image ── #}
<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">
{# Empty state: add link #}
<div id="feature-image-empty">
<button
type="button"
id="feature-image-add-btn"
class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
>+ Add feature image</button>
</div>
{# Filled state: image preview + controls #}
<div id="feature-image-filled" class="relative hidden">
<img
id="feature-image-preview"
src=""
alt=""
class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer"
>
{# Delete button (top-right, visible on hover) #}
<button
type="button"
id="feature-image-delete-btn"
class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white
flex items-center justify-center opacity-0 group-hover:opacity-100
transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
title="Remove feature image"
><i class="fa-solid fa-trash-can"></i></button>
{# Caption input #}
<input
type="text"
id="feature-image-caption"
value=""
placeholder="Add a caption..."
class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none
outline-none placeholder:text-stone-300 focus:text-stone-700"
>
</div>
{# Upload spinner overlay #}
<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">
<i class="fa-solid fa-spinner fa-spin"></i> Uploading...
</div>
{# Hidden file input #}
<input
type="file"
id="feature-image-file"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
class="hidden"
>
</div>
{# ── Title ── #}
<input
type="text"
name="title"
value=""
placeholder="Post title..."
class="w-full text-[36px] font-bold bg-transparent border-none outline-none
placeholder:text-stone-300 mb-[8px] leading-tight"
>
{# ── Excerpt ── #}
<textarea
name="custom_excerpt"
rows="1"
placeholder="Add an excerpt..."
class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none
placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
></textarea>
{# ── Editor mount point ── #}
<div id="lexical-editor" class="relative w-full bg-transparent"></div>
{# ── Status + Save footer ── #}
<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">
<select
name="status"
class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"
>
<option value="draft" selected>Draft</option>
<option value="published">Published</option>
</select>
<button
type="submit"
class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]
hover:bg-stone-800 transition-colors cursor-pointer"
>Create Post</button>
</div>
</form>
{# ── Koenig editor assets ── #}
<link rel="stylesheet" href="{{ asset_url('scripts/editor.css') }}">
<style>
/* Koenig CSS uses rem, designed for Ghost Admin's html{font-size:62.5%}.
We apply that via JS (see init() below) so the header bars render at
normal size on first paint. A beforeSwap listener restores the
default when navigating away. */
#lexical-editor { display: flow-root; }
/* Reset floats inside HTML cards to match Ghost Admin behaviour */
#lexical-editor [data-kg-card="html"] * { float: none !important; }
#lexical-editor [data-kg-card="html"] table { width: 100% !important; }
</style>
<script src="{{ asset_url('scripts/editor.js') }}"></script>
<script>
(function() {
/* ── Koenig rem fix: apply 62.5% root font-size for the editor,
restore default when navigating away via HTMX ── */
function applyEditorFontSize() {
document.documentElement.style.fontSize = '62.5%';
document.body.style.fontSize = '1.6rem';
}
function restoreDefaultFontSize() {
document.documentElement.style.fontSize = '';
document.body.style.fontSize = '';
}
applyEditorFontSize();
document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {
if (e.detail.target && e.detail.target.id === 'main-panel') {
restoreDefaultFontSize();
document.body.removeEventListener('htmx:beforeSwap', cleanup);
}
});
function init() {
var csrfToken = document.querySelector('input[name="csrf_token"]').value;
var uploadUrl = '{{ url_for("blog.editor_api.upload_image") }}';
var uploadUrls = {
image: uploadUrl,
media: '{{ url_for("blog.editor_api.upload_media") }}',
file: '{{ url_for("blog.editor_api.upload_file") }}',
};
/* ── Feature image upload / delete / replace ── */
var fileInput = document.getElementById('feature-image-file');
var addBtn = document.getElementById('feature-image-add-btn');
var deleteBtn = document.getElementById('feature-image-delete-btn');
var preview = document.getElementById('feature-image-preview');
var emptyState = document.getElementById('feature-image-empty');
var filledState = document.getElementById('feature-image-filled');
var hiddenUrl = document.getElementById('feature-image-input');
var hiddenCaption = document.getElementById('feature-image-caption-input');
var captionInput = document.getElementById('feature-image-caption');
var uploading = document.getElementById('feature-image-uploading');
function showFilled(url) {
preview.src = url;
hiddenUrl.value = url;
emptyState.classList.add('hidden');
filledState.classList.remove('hidden');
uploading.classList.add('hidden');
}
function showEmpty() {
preview.src = '';
hiddenUrl.value = '';
hiddenCaption.value = '';
captionInput.value = '';
emptyState.classList.remove('hidden');
filledState.classList.add('hidden');
uploading.classList.add('hidden');
}
function uploadFile(file) {
emptyState.classList.add('hidden');
uploading.classList.remove('hidden');
var fd = new FormData();
fd.append('file', file);
fetch(uploadUrl, {
method: 'POST',
body: fd,
headers: { 'X-CSRFToken': csrfToken },
})
.then(function(r) {
if (!r.ok) throw new Error('Upload failed (' + r.status + ')');
return r.json();
})
.then(function(data) {
var url = data.images && data.images[0] && data.images[0].url;
if (url) showFilled(url);
else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }
})
.catch(function(e) {
showEmpty();
alert(e.message);
});
}
addBtn.addEventListener('click', function() { fileInput.click(); });
preview.addEventListener('click', function() { fileInput.click(); });
deleteBtn.addEventListener('click', function(e) {
e.stopPropagation();
showEmpty();
});
fileInput.addEventListener('change', function() {
if (fileInput.files && fileInput.files[0]) {
uploadFile(fileInput.files[0]);
fileInput.value = '';
}
});
captionInput.addEventListener('input', function() {
hiddenCaption.value = captionInput.value;
});
/* ── Auto-resize excerpt textarea ── */
var excerpt = document.querySelector('textarea[name="custom_excerpt"]');
function autoResize() {
excerpt.style.height = 'auto';
excerpt.style.height = excerpt.scrollHeight + 'px';
}
excerpt.addEventListener('input', autoResize);
autoResize();
/* ── Mount Koenig editor ── */
window.mountEditor('lexical-editor', {
initialJson: null,
csrfToken: csrfToken,
uploadUrls: uploadUrls,
oembedUrl: '{{ url_for("blog.editor_api.oembed_proxy") }}',
unsplashApiKey: '{{ unsplash_api_key or "" }}',
snippetsUrl: '{{ url_for("blog.editor_api.list_snippets") }}',
});
/* ── Ctrl-S / Cmd-S to save ── */
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
document.getElementById('post-new-form').requestSubmit();
}
});
}
/* editor.js loads synchronously on full page loads but asynchronously
when HTMX swaps the content in, so wait for it if needed. */
if (typeof window.mountEditor === 'function') {
init();
} else {
var _t = setInterval(function() {
if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }
}, 50);
}
})();
</script>

View File

@@ -0,0 +1,12 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/blog/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('root-blog-header', '_types/blog/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/blog_new/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% import "macros/links.html" as links %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for('market.browse.product.admin', slug=slug)
)}}
{% endif %}

View File

@@ -0,0 +1,5 @@
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">
{% include "_types/browse/_product_cards.html" %}
</div>
<div class="pb-8"></div>

View File

@@ -0,0 +1,37 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'market-header-child', '_types/market/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/market/mobile/_nav_panel.html' %}
{% endblock %}
{# Filter container with child summary - from browse/index.html child_summary block #}
{% block filter %}
{% include "_types/browse/mobile/_filter/summary.html" %}
{% endblock %}
{% block aside %}
{% include "_types/browse/desktop/menu.html" %}
{% endblock %}
{% block content %}
{% include "_types/browse/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(p, prices_ns) }}
{% set item_href = url_for('market.browse.product.product_detail', slug=p.slug)|host %}
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
{# ❤️ like button overlay - OUTSIDE the link #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-6xl md:text-xl">
{% set slug = p.slug %}
{% set liked = p.is_liked or False %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class=""
>
{# Make this relative so we can absolutely position children #}
<div class="w-full aspect-square bg-stone-100 relative">
{% if p.image %}
<figure class="inline-block w-full h-full">
<div class="relative w-full h-full">
<img
src="{{ p.image }}"
alt="no image"
class="absolute inset-0 w-full h-full object-contain object-top"
loading="lazy" decoding="async" fetchpriority="low"
/>
{% for l in p.labels %}
<img
src="{{ asset_url('labels/' + l + '.svg') }}"
alt=""
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
/>
{% endfor %}
</div>
<figcaption class="
mt-2 text-sm text-center
{{ 'bg-yellow-200' if p.brand in selected_brands else '' }}
text-stone-600
">
{{ p.brand }}
</figcaption>
</figure>
{% else %}
<div class="p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative">
<div class="text-stone-400 text-xs">No image</div>
<ul class="flex flex-row gap-1">
{% for l in p.labels %}
<li>{{ l }}</li>
{% endfor %}
</ul>
<div class="text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.brand }}
</div>
</div>
{% endif %}
</div>
{# <div>{{ prices.rrp(prices_ns) }}</div> #}
{{ prices.card_price(p)}}
{% import '_types/product/_cart.html' as _cart %}
</a>
<div class="flex justify-center">
{{ _cart.add(p.slug, cart)}}
</div>
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div class="flex flex-row justify-center gap-2 p-2">
{% for s in p.stickers %}
{{ stick.sticker(
asset_url('stickers/' + s + '.svg'),
s,
True,
size=24,
found=s in selected_stickers
) }}
{% endfor %}
</div>
<div class="text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.title | highlight(search) }}
</div>
</a>
</div>

View File

@@ -0,0 +1,107 @@
{% for p in products %}
{% include "_types/browse/_product_card.html" %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -0,0 +1,40 @@
{# Categories #}
<nav aria-label="Categories"
class="rounded-xl border bg-white shadow-sm min-h-0">
<ul class="divide-y">
{% set top_active = (current_local_href == top_local_href) %}
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if top_active else 'false' }}"
class="block px-4 py-3 text-[15px] transition {{select_colours}}">
<div class="prose prose-stone max-w-none">All products</div>
</a>
</li>
{% for sub in subs_local %}
{% set active = (current_local_href == sub.local_href) %}
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if active else 'false' }}"
class="block px-4 py-3 text-[15px] border-l-4 transition {{select_colours}}"
>
<div class="prose prose-stone max-w-none">{{ (sub.html_label or sub.name) | safe }}</div>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,40 @@
{# Brand filter (desktop, single-select) #}
{# Brands #}
<nav aria-label="Brands"
class="rounded-xl border bg-white shadow-sm">
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="divide-y">
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
{% if is_selected %}
{% set brand_href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set brand_href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{% endif %}
<li>
<a
href="{{ brand_href }}"
hx-get="{{ brand_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML" hx-push-url="true" hx-on:htmx:afterSwap="this.closest('details')?.removeAttribute('open')"
class="flex items-center gap-2 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="{% if b.count==0 %}text-lg text-red-500{% else %}text-sm{% endif %} {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,44 @@
{% import 'macros/stickers.html' as stick %}
<ul
id="labels-details-desktop"
class="flex justify-center p-0 m-0 gap-2"
>
{% for s in labels %}
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
{% set qs = {"remove_label": s.name, "page":None}|qs if is_on
else {"add_label": s.name, "page":None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,38 @@
{% import 'macros/stickers.html' as stick %}
{% set qs = {"liked": None if liked else True, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-300 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>

View File

@@ -0,0 +1,44 @@
{% macro search(current_local_href,search, search_count, hx_select) -%}
<!-- Search (1/3 width → 4/12 columns) -->
<!-- nb this does NOT oob itself!! -->
<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 %}

View File

@@ -0,0 +1,34 @@
{% import 'macros/stickers.html' as stick %}
{% set sort_val = sort|default('az', true) %}
<ul
id="sort-details-desktop"
class="flex w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-0 [&>li]:list-none [&>li]:flex-1"
>
{% for key,label,icon in sort_options %}
{% set is_on = (sort_val == key) %}
{% set qs = {"sort": None, "page": None}|qs if is_on
else {"sort": key, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
class="flex flex-col items-center justify-center w-full h-full py-2 m-0"
>
{{ stick.sticker(asset_url(icon), label, is_on) }}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,46 @@
{% import 'macros/stickers.html' as stick %}
<ul
id="stickers-details-desktop"
class="flex flex-wrap justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1 [&>li]:list-none [&>li]:basis-[20%] [&>li]:max-w-[20%] [&>li]:grow-0"
>
{% for s in stickers %}
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
else {"add_sticker": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host%}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<span class="text-[11px]">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,37 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
<div class="mb-4">
<div class="text-2xl uppercase tracking-wide text-black-500">{{ category_label }}</div>
</div>
{% include "_types/browse/desktop/_filter/sort.html" %}
<nav aria-label="like" class="flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1">
{% include "_types/browse/desktop/_filter/like.html" %}
{% if labels %}
{% include "_types/browse/desktop/_filter/labels.html" %}
{% endif %}
</nav>
{% if stickers %}
{% include "_types/browse/desktop/_filter/stickers.html" %}
{% endif %}
{% if subs_local and top_local_href %}
{% include "_types/browse/desktop/_category_selector.html" %}
{% endif %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include "_types/browse/desktop/_filter/brand.html" %}
</div>

View File

@@ -0,0 +1,13 @@
{% extends '_types/market/index.html' %}
{% block filter %}
{% include "_types/browse/mobile/_filter/summary.html" %}
{% endblock %}
{% block aside %}
{% include "_types/browse/desktop/menu.html" %}
{% endblock %}
{% block content %}
{% include "_types/browse/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
<button
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', slug=slug)|host }}"
hx-target="this"
hx-swap="outerHTML"
hx-push-url="false"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-swap-settle="0ms"
{% if liked %}
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
{% else %}
aria-label="Like this {{ item_type if item_type else 'product' }}"
{% endif %}
>
{% if liked %}
<i aria-hidden="true" class="fa-solid fa-heart"></i>
{% else %}
<i aria-hidden="true" class="fa-regular fa-heart"></i>
{% endif %}
</button>

View File

@@ -0,0 +1,40 @@
<nav aria-label="Brands" class="px-4 pb-3" >
{% if brands|length %}
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="space-y-1 pr-1" >
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
<li>
{{current_local_href}}
<a
{% if is_selected %}
{% set href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{%endif%}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="flex items-center gap-2 my-3 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="text-xs {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</nav>

View File

@@ -0,0 +1,30 @@
{% include "_types/browse/mobile/_filter/sort_ul.html" %}
{% if search or selected_labels|length or selected_stickers|length or selected_brands|length %}
{% set href = (current_local_href ~ {"clear_filters": True}|qs)|host %}
<div class = "flex flex-row justify-center">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
title="clear filters"
aria-label="clear filters"
class="flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer">
<span class="mt-1 leading-none tabular-nums"
>
clear filters
</span>
</a>
</div>
{% endif %}
<div class="flex flex-row gap-2 justify-center items center">
{% include "_types/browse/mobile/_filter/like.html" %}
{% include "_types/browse/mobile/_filter/labels.html" %}
</div>
{% include "_types/browse/mobile/_filter/stickers.html" %}
{% include "_types/browse/mobile/_filter/brand_ul.html" %}

View File

@@ -0,0 +1,47 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="labels" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% for s in labels %}
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
{% set qs = {"remove_label": s.name, "page": None}|qs if is_on
else {"add_label": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li class="list-none shrink-0">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -0,0 +1,40 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="like" class="px-4 pb-3">
{% set qs = {"liked": None if liked else True, "page": None}|qs%}
{% set href = (current_local_href ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-500 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>
</nav>

View File

@@ -0,0 +1,40 @@
{% macro search(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 %}

View File

@@ -0,0 +1,33 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="sort" class="px-4 pb-3" >
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
{% for key,label,icon in sort_options %}
<li class="list-none">
<div class="flex flex-col items-center justify-center w-full">
<a
{% if sort == key %}
{% set href= (current_local_href, {"sort": None, "page": None}|qs )|host %}
{% else %}
{% set href= (current_local_href ~ {"sort": key, "page": None}|qs )|host %}
{% endif %}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{ stick.sticker(asset_url(icon), label, sort==key) }}
</a>
</div>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,50 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="stickers" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% for s in stickers %}
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
else {"add_sticker": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li class="list-none shrink-0">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
<span class="text-sm">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on) }}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -0,0 +1,120 @@
{% import 'macros/stickers.html' as stick %}
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
<div
class="col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2"
role="list">
<div class="flex flex-row items-start gap-2">
{% if sort %}
<ul class="relative inline-flex items-center justify-center gap-2">
<!-- sticker icon -->
{% for k,l,i in sort_options %}
{% if k == sort %}
{% set key = k %}
{% set label = l %}
{% set icon = i %}
<li role="listitem">
{{ stick.sticker(asset_url(icon), label, True)}}
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% if liked %}
<div class="flex flex-col items-center gap-1 pb-1">
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% if liked_count is not none %}
<div class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums"
>
{{ liked_count }}
</div>
{% endif %}
</div>
{% endif %}
{% if selected_labels and selected_labels|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_labels %}
{% for s in labels %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<div class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</div>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
{% if selected_stickers and selected_stickers|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_stickers %}
{% for s in stickers %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
<!-- sticker icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
</div>
{% if selected_brands and selected_brands|length %}
<ul class_="w-full grid grid-cols-12 items-center gap-3 px-4 py-3">
{% for b in selected_brands %}
<li role="listitem" class="flex flex-row items-center gap-2">
{% set ns = namespace(count=0) %}
{% for brand in brands %}
{% if brand.name == b %}
{% set ns.count = brand.count %}
{% endif %}
{% endfor %}
{% if ns.count %}
<div class="text-md">{{ b }}</div>
<div class="text-md">{{ ns.count }}</div>
{% else %}
<div class="text-md text-red-500">{{ b }}</div>
<div class="text-xl text-red-500">0</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endcall %}
<div id="filter-details-mobile" style="display:contents">
{% include "_types/browse/mobile/_filter/index.html" %}
</div>
{% endcall %}

View File

@@ -0,0 +1,12 @@
{% macro description(calendar, oob=False) %}
<div
id="calendar-description-title"
{% if oob %}
hx-swap-oob="outerHTML"
{% endif %}
class="text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"
>
{{ calendar.description or ''}}
</div>
{% endmacro %}

View File

@@ -0,0 +1,180 @@
<section class="bg-orange-100">
<header class="flex items-center justify-center mt-2">
{# Month / year navigation #}
<nav class="flex items-center gap-2 text-2xl">
{# Outer left: -1 year #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
hx-get="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
>
&laquo;
</a>
{# Inner left: -1 month #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_month_year,
month=prev_month) }}"
hx-get="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=prev_month_year,
month=prev_month) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
>
&lsaquo;
</a>
<div class="px-3 font-medium">
{{ month_name }} {{ year }}
</div>
{# Inner right: +1 month #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_month_year,
month=next_month) }}"
hx-get="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_month_year,
month=next_month) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
>
&rsaquo;
</a>
{# Outer right: +1 year #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_year,
month=month) }}"
hx-get="{{ url_for('calendars.calendar.get',
slug=post.slug,
calendar_slug=calendar.slug,
year=next_year,
month=month) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
>
&raquo;
</a>
</nav>
</header>
{# Calendar grid #}
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4">
{# Weekday header: only show on sm+ (desktop/tablet) #}
<div class="hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2">
{% for wd in weekday_names %}
<div class="py-1">{{ wd }}</div>
{% endfor %}
</div>
{# On mobile: 1 column; on sm+: 7 columns #}
<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden">
{% for week in weeks %}
{% for day in week %}
<div
class="min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs {% if not day.in_month %} bg-stone-50 text-stone-400{% endif %} {% if day.is_today %} ring-2 ring-blue-500 z-10 relative {% endif %}"
>
<div class="flex justify-between items-center">
<div class="flex flex-col">
<span class="sm:hidden text-[16px] text-stone-500">
{{ day.date.strftime('%a') }}
</span>
{# Clickable day number: goes to day detail view #}
<a
class="{{styles.pill}}"
href="{{ url_for('calendars.calendar.day.show_day',
slug=post.slug,
calendar_slug=calendar.slug,
year=day.date.year,
month=day.date.month,
day=day.date.day) }}"
hx-get="{{ url_for('calendars.calendar.day.show_day',
slug=post.slug,
calendar_slug=calendar.slug,
year=day.date.year,
month=day.date.month,
day=day.date.day) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{ day.date.day }}
</a>
</div>
</div>
{# Entries for this day: merged, chronological #}
<div class="mt-1 space-y-0.5">
{# Build a list of entries for this specific day.
month_entries is already sorted by start_at in Python. #}
{% for e in month_entries %}
{% if e.start_at.date() == day.date %}
{# Decide colour: highlight "mine" differently if you want #}
{% set is_mine = (g.user and e.user_id == g.user.id)
or (not g.user and e.session_id == qsession.get('calendar_sid')) %}
<div class="flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5
{% if e.state == 'confirmed' %}
{% if is_mine %}
bg-emerald-200 text-emerald-900
{% else %}
bg-emerald-100 text-emerald-800
{% endif %}
{% else %}
{% if is_mine %}
bg-sky-100 text-sky-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
{% endif %}">
<span class="truncate">
{{ e.name }}
</span>
<span class="shrink-0 text-[10px] font-semibold uppercase tracking-tight">
{{ (e.state or 'pending')|replace('_', ' ') }}
</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,12 @@
<!-- Desktop nav -->
{% import 'macros/links.html' as links %}
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/slots/') }}" class="{{styles.nav_button}}">
<i class="fa fa-clock" aria-hidden="true"></i>
<div>Slots</div>
</a>
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
<a href="{{ events_url('/' + post.slug + '/calendars/' + calendar.slug + '/admin/') }}" class="{{styles.nav_button}}">
<i class="fa fa-cog" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -0,0 +1,22 @@
{% extends "oob_elements.html" %}
{# OOB elements for post admin page #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-header-child', 'calendar-header-child', '_types/calendar/header/_header.html')}}
{% from '_types/post/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/calendar/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/calendar/_main_panel.html' %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More