Monorepo: consolidate 7 repos into one
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
1
shared/browser/__init__.py
Normal file
1
shared/browser/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# suma_browser package
|
||||
12
shared/browser/app/__init__.py
Normal file
12
shared/browser/app/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# The monolith has been split into three apps (apps/blog, 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.blog.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
shared/browser/app/authz.py
Normal file
152
shared/browser/app/authz.py
Normal 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
shared/browser/app/csrf.py
Normal file
99
shared/browser/app/csrf.py
Normal 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
shared/browser/app/errors.py
Normal file
126
shared/browser/app/errors.py
Normal 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)
|
||||
17
shared/browser/app/filters/__init__.py
Normal file
17
shared/browser/app/filters/__init__.py
Normal 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)
|
||||
25
shared/browser/app/filters/combine.py
Normal file
25
shared/browser/app/filters/combine.py
Normal 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}
|
||||
12
shared/browser/app/filters/currency.py
Normal file
12
shared/browser/app/filters/currency.py
Normal 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}"
|
||||
6
shared/browser/app/filters/getattr.py
Normal file
6
shared/browser/app/filters/getattr.py
Normal 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)
|
||||
21
shared/browser/app/filters/highlight.py
Normal file
21
shared/browser/app/filters/highlight.py
Normal 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
shared/browser/app/filters/qs.py
Normal file
13
shared/browser/app/filters/qs.py
Normal 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 ""
|
||||
78
shared/browser/app/filters/qs_base.py
Normal file
78
shared/browser/app/filters/qs_base.py
Normal 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
|
||||
33
shared/browser/app/filters/query_types.py
Normal file
33
shared/browser/app/filters/query_types.py
Normal 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
|
||||
22
shared/browser/app/filters/truncate.py
Normal file
22
shared/browser/app/filters/truncate.py
Normal 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] + "…"
|
||||
19
shared/browser/app/filters/url_join.py
Normal file
19
shared/browser/app/filters/url_join.py
Normal 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
shared/browser/app/middleware.py
Normal file
58
shared/browser/app/middleware.py
Normal 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)
|
||||
1
shared/browser/app/payments/__init__.py
Normal file
1
shared/browser/app/payments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
133
shared/browser/app/payments/sumup.py
Normal file
133
shared/browser/app/payments/sumup.py
Normal file
@@ -0,0 +1,133 @@
|
||||
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 shared.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,
|
||||
page_config: Any | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
settings = _sumup_settings()
|
||||
|
||||
# Per-page SumUp credentials override globals
|
||||
if page_config and getattr(page_config, "sumup_api_key", None):
|
||||
settings["api_key"] = page_config.sumup_api_key
|
||||
if page_config and getattr(page_config, "sumup_merchant_code", None):
|
||||
settings["merchant_code"] = page_config.sumup_merchant_code
|
||||
|
||||
# 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, page_config: Any | None = None) -> Dict[str, Any]:
|
||||
"""Fetch checkout status/details from SumUp."""
|
||||
settings = _sumup_settings()
|
||||
|
||||
if page_config and getattr(page_config, "sumup_api_key", None):
|
||||
settings["api_key"] = page_config.sumup_api_key
|
||||
|
||||
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
shared/browser/app/redis_cacher.py
Normal file
346
shared/browser/app/redis_cacher.py
Normal 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
|
||||
12
shared/browser/app/utils/__init__.py
Normal file
12
shared/browser/app/utils/__init__.py
Normal 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
shared/browser/app/utils/htmx.py
Normal file
46
shared/browser/app/utils/htmx.py
Normal 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()
|
||||
36
shared/browser/app/utils/parse.py
Normal file
36
shared/browser/app/utils/parse.py
Normal 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
shared/browser/app/utils/utc.py
Normal file
6
shared/browser/app/utils/utc.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
51
shared/browser/app/utils/utils.py
Normal file
51
shared/browser/app/utils/utils.py
Normal 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
|
||||
|
||||
|
||||
33
shared/browser/templates/_oob_elements.html
Normal file
33
shared/browser/templates/_oob_elements.html
Normal 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 %}
|
||||
|
||||
|
||||
11
shared/browser/templates/_types/root/_full_user.html
Normal file
11
shared/browser/templates/_types/root/_full_user.html
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
{% set href=account_url('/') %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<span>{{g.user.email}}</span>
|
||||
</a>
|
||||
|
||||
13
shared/browser/templates/_types/root/_hamburger.html
Normal file
13
shared/browser/templates/_types/root/_hamburger.html
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
<div class="md:hidden bg-stone-200 rounded">
|
||||
<svg class="h-12 w-12 transition-transform group-open/root:hidden block self-start" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"
|
||||
class="w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start">
|
||||
<path d="M6 9l6 6 6-6" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
67
shared/browser/templates/_types/root/_head.html
Normal file
67
shared/browser/templates/_types/root/_head.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<style>
|
||||
@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }
|
||||
</style>
|
||||
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/basics.css')}}">
|
||||
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/cards.css')}}">
|
||||
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/blog-content.css')}}">
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="{{asset_url('fontawesome/css/all.min.css')}}">
|
||||
<link rel="stylesheet" href="{{asset_url('fontawesome/css/v4-shims.min.css')}}">
|
||||
<link href="https://unpkg.com/prismjs/themes/prism.css" rel="stylesheet" />
|
||||
<script src="https://unpkg.com/prismjs/prism.js"></script>
|
||||
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
|
||||
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
|
||||
<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
|
||||
<script>
|
||||
if (matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
||||
document.documentElement.classList.add('hover-capable');
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('click', function (e) {
|
||||
const closeTarget = e.target.closest('[data-close-details]');
|
||||
if (!closeTarget) return;
|
||||
|
||||
const details = closeTarget.closest('details');
|
||||
if (details) {
|
||||
details.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
/* hide disclosure glyph */
|
||||
details[data-toggle-group="mobile-panels"] > summary {
|
||||
list-style: none;
|
||||
}
|
||||
details[data-toggle-group="mobile-panels"] > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Desktop hover/focus dropdowns */
|
||||
@media (min-width: 768px) {
|
||||
.nav-group:focus-within .submenu,
|
||||
.nav-group:hover .submenu { display:block }
|
||||
}
|
||||
img { max-width: 100%; height: auto; }
|
||||
.clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
details.group { overflow: hidden; }
|
||||
details.group > summary { list-style: none; }
|
||||
details.group > summary::-webkit-details-marker { display:none; }
|
||||
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator { display: inline-flex; }
|
||||
</style>
|
||||
<style>
|
||||
.js-wrap.open .js-pop { display:block; }
|
||||
.js-wrap.open .js-backdrop { display:block; }
|
||||
</style>
|
||||
13
shared/browser/templates/_types/root/_index.html
Normal file
13
shared/browser/templates/_types/root/_index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends '_types/root/index.html' %}
|
||||
{% from 'macros/glyphs.html' import opener %}
|
||||
{% from 'macros/title.html' import title with context %}
|
||||
{% block main_mobile_menu %}
|
||||
<div class="flex flex-col gap-2 md:hidden z-40">
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/root/_nav.html' %}
|
||||
{% include '_types/root/_nav_panel.html' %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
35
shared/browser/templates/_types/root/_n/macros.html
Normal file
35
shared/browser/templates/_types/root/_n/macros.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% macro header(id=False, oob=False) %}
|
||||
<div
|
||||
{% if id %}id="{{id}}"{% endif %}
|
||||
{% if oob %}hx-swap-oob="outerHTML"{% endif %}
|
||||
class="w-full"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro oob_header(id, child_id, row_macro) %}
|
||||
{% call header(id=id, oob=True) %}
|
||||
{% call header() %}
|
||||
{% from row_macro import header_row with context %}
|
||||
{{header_row()}}
|
||||
<div id="{{child_id}}">
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro index_row(id, row_macro) %}
|
||||
{% from '_types/root/_n/macros.html' import header with context %}
|
||||
{% set _caller = caller %}
|
||||
{% call header() %}
|
||||
{% from row_macro import header_row with context %}
|
||||
{{ header_row() }}
|
||||
<div id="{{id}}">
|
||||
{{_caller()}}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{% endmacro %}
|
||||
29
shared/browser/templates/_types/root/_nav.html
Normal file
29
shared/browser/templates/_types/root/_nav.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% set _app_slugs = {
|
||||
'cart': cart_url('/'),
|
||||
'market': market_url('/'),
|
||||
'events': events_url('/'),
|
||||
'federation': federation_url('/'),
|
||||
'account': account_url('/'),
|
||||
} %}
|
||||
{% set _first_seg = request.path.strip('/').split('/')[0] %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="menu-items-nav-wrapper">
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(item) scrolling_menu('menu-items-container', menu_items) %}
|
||||
{% set _href = _app_slugs.get(item.slug, blog_url('/' + item.slug + '/')) %}
|
||||
<a
|
||||
href="{{ _href }}"
|
||||
aria-selected="{{ 'true' if (item.slug == _first_seg or item.slug == app_name) else 'false' }}"
|
||||
class="{{styles.nav_button_less_pad}}"
|
||||
>
|
||||
{% if item.feature_image %}
|
||||
<img src="{{ item.feature_image }}"
|
||||
alt="{{ item.label }}"
|
||||
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
|
||||
{% endif %}
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
{% endcall %}
|
||||
</div>
|
||||
7
shared/browser/templates/_types/root/_nav_panel.html
Normal file
7
shared/browser/templates/_types/root/_nav_panel.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% if g.rights.admin %}
|
||||
<a href="{{ blog_url('/settings/') }}" class="{{styles.nav_button}}">
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
46
shared/browser/templates/_types/root/_oob_menu.html
Normal file
46
shared/browser/templates/_types/root/_oob_menu.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{#
|
||||
Shared mobile menu for both base templates and OOB updates
|
||||
|
||||
This macro can be used in two modes:
|
||||
- oob=true: Outputs full wrapper with hx-swap-oob attribute (for OOB updates)
|
||||
- oob=false: Outputs just content, assumes wrapper exists (for base templates)
|
||||
|
||||
The caller can pass section-specific nav items via section_nav parameter.
|
||||
#}
|
||||
|
||||
{% macro mobile_menu(section_nav='', oob=true) %}
|
||||
{% if oob %}
|
||||
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
|
||||
{% endif %}
|
||||
<nav id="nav-panel" {% if oob %}hx-swap-oob="true"{% endif %} class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
|
||||
{% if not g.user %}
|
||||
{% include '_types/root/_sign_in.html' %}
|
||||
{% endif %}
|
||||
{% include '_types/root/_nav.html' %}
|
||||
|
||||
{# Section-specific mobile nav #}
|
||||
{% if section_nav %}
|
||||
{{ section_nav }}
|
||||
{% else %}
|
||||
{% include "_types/root/_nav_panel.html"%}
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% if oob %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{% macro oob_mobile_menu() %}
|
||||
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
|
||||
<nav id="nav-panel" class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
|
||||
{{caller()}}
|
||||
</nav>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
10
shared/browser/templates/_types/root/_sign_in.html
Normal file
10
shared/browser/templates/_types/root/_sign_in.html
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
<a
|
||||
href="{{ account_url('/') }}"
|
||||
aria-selected="{{ 'true' if '/auth/login' in request.path else 'false' }}"
|
||||
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>sign in or register</span>
|
||||
</a>
|
||||
@@ -0,0 +1 @@
|
||||
{{asset_url('errors/403.gif')}}
|
||||
@@ -0,0 +1 @@
|
||||
YOU CAN'T DO THAT
|
||||
@@ -0,0 +1 @@
|
||||
{{asset_url('errors/404.gif')}}
|
||||
@@ -0,0 +1 @@
|
||||
NOT FOUND
|
||||
12
shared/browser/templates/_types/root/exceptions/_.html
Normal file
12
shared/browser/templates/_types/root/exceptions/_.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends '_types/root/exceptions/base.html' %}
|
||||
|
||||
{% block error_summary %}
|
||||
<div>
|
||||
{% include '_types/root/exceptions/' + errnum + '/message.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block error_content %}
|
||||
<img src="{% include '_types/root/exceptions/' + errnum + '/img.html' %}" width="300px" height="300px"/>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh] p-8">
|
||||
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-6">
|
||||
<div class="flex items-center justify-center w-12 h-12 mx-auto mb-4 rounded-full bg-red-100">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-semibold text-center text-stone-800 mb-4">
|
||||
Something went wrong
|
||||
</h1>
|
||||
|
||||
{% if messages %}
|
||||
<div class="space-y-2 mb-6">
|
||||
{% for message in messages %}
|
||||
<div class="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
onclick="history.back()"
|
||||
class="px-4 py-2 border border-stone-300 text-stone-700 rounded hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
← Go Back
|
||||
</button>
|
||||
<a
|
||||
href="{{ blog_url('/') }}"
|
||||
class="px-4 py-2 bg-stone-800 text-white rounded hover:bg-stone-700 transition-colors text-center"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
shared/browser/templates/_types/root/exceptions/base.html
Normal file
17
shared/browser/templates/_types/root/exceptions/base.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends '_types/root/index.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
class="w-full flex justify-center font-bold text-2xl md:text-4xl px-2 flex-1 text-red-500"
|
||||
>
|
||||
{% block error_summary %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div
|
||||
class="w-full flex justify-center"
|
||||
>
|
||||
{% block error_content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
12
shared/browser/templates/_types/root/exceptions/error.html
Normal file
12
shared/browser/templates/_types/root/exceptions/error.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends '_types/root/exceptions/base.html' %}
|
||||
|
||||
{% block error_summary %}
|
||||
<div>
|
||||
WELL THIS IS EMBARASSING...
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block error_content %}
|
||||
<img src="{{asset_url('errors/error.gif')}}" width="300px" height="300px"/>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<div>
|
||||
{% include '_types/root/exceptions/' + errnum + '/message.html' %}
|
||||
</div>
|
||||
|
||||
<img src="{% include '_types/root/exceptions/' + errnum + '/img.html' %}" width="300px" height="300px"/>
|
||||
|
||||
</div>
|
||||
41
shared/browser/templates/_types/root/header/_header.html
Normal file
41
shared/browser/templates/_types/root/header/_header.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% set select_colours = "
|
||||
[.hover-capable_&]:hover:bg-yellow-300
|
||||
aria-selected:bg-stone-500 aria-selected:text-white
|
||||
[.hover-capable_&[aria-selected=true]:hover]:bg-orange-500
|
||||
"%}
|
||||
{% import 'macros/links.html' as links %}
|
||||
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='root-row', oob=oob) %}
|
||||
<div class="w-full flex flex-row items-top">
|
||||
{# Cart mini — fetched from cart app as fragment #}
|
||||
{% if cart_mini_html %}
|
||||
{{ cart_mini_html | safe }}
|
||||
{% endif %}
|
||||
|
||||
{# Site title #}
|
||||
<div class="font-bold text-5xl flex-1">
|
||||
{% from 'macros/title.html' import title with context %}
|
||||
{{ title('flex justify-center md:justify-start')}}
|
||||
</div>
|
||||
|
||||
{# Desktop nav #}
|
||||
<nav class="hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0">
|
||||
{% if nav_tree_html %}
|
||||
{{ nav_tree_html | safe }}
|
||||
{% endif %}
|
||||
{% if auth_menu_html %}
|
||||
{{ auth_menu_html | safe }}
|
||||
{% endif %}
|
||||
{% include "_types/root/_nav_panel.html"%}
|
||||
</nav>
|
||||
{% include '_types/root/_hamburger.html' %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{# Mobile user info #}
|
||||
<div class="block md:hidden text-md font-bold">
|
||||
{% if auth_menu_html %}
|
||||
{{ auth_menu_html | safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
67
shared/browser/templates/_types/root/header/_oob.html
Normal file
67
shared/browser/templates/_types/root/header/_oob.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{#
|
||||
Shared root header for both base templates and OOB updates
|
||||
|
||||
This macro can be used in two modes:
|
||||
- oob=true: Outputs full div with hx-swap-oob attribute (for OOB updates)
|
||||
- oob=false: Outputs just content, assumes wrapper div exists (for base templates)
|
||||
|
||||
Usage:
|
||||
1. Call root_header_start(oob=true/false)
|
||||
2. Add any section-specific headers
|
||||
3. Call root_header_end(oob=true/false)
|
||||
#}
|
||||
|
||||
{% macro root_header_start(oob=true) %}
|
||||
{% set select_colours = "
|
||||
[.hover-capable_&]:hover:bg-yellow-300
|
||||
aria-selected:bg-stone-500 aria-selected:text-white
|
||||
[.hover-capable_&[aria-selected=true]:hover]:bg-orange-500
|
||||
"%}
|
||||
|
||||
{% if oob %}
|
||||
<div id="root-header" hx-swap-oob="outerHTML" class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
|
||||
{% endif %}
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
<div class="flex w-full justify-center md:justify-start">
|
||||
{# Cart mini — rendered via fragment #}
|
||||
{% if cart_mini_html %}
|
||||
{{ cart_mini_html | safe }}
|
||||
{% endif %}
|
||||
|
||||
{# Site title #}
|
||||
<div class="font-bold text-5xl flex-1">
|
||||
{% from 'macros/title.html' import title with context %}
|
||||
{{ title('flex justify-center md:justify-start')}}
|
||||
</div>
|
||||
|
||||
{# Desktop nav #}
|
||||
<nav class="hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0">
|
||||
{% include '_types/root/_nav.html' %}
|
||||
{% if not g.user %}
|
||||
{% include '_types/root/_sign_in.html' %}
|
||||
{% else %}
|
||||
{% include '_types/root/_full_user.html' %}
|
||||
{% endif %}
|
||||
{% include "_types/root/_nav_panel.html"%}
|
||||
</nav>
|
||||
{% include '_types/root/_hamburger.html' %}
|
||||
</div>
|
||||
|
||||
{# Mobile user info #}
|
||||
<div class="block md:hidden text-md font-bold">
|
||||
{% if g.user %}
|
||||
{% include '_types/root/mobile/_full_user.html' %}
|
||||
{% else %}
|
||||
{% include '_types/root/mobile/_sign_in.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Section-specific headers go here (caller adds them between start and end) #}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro root_header_end(oob=true) %}
|
||||
</div>
|
||||
{% if oob %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
38
shared/browser/templates/_types/root/header/_oob_.html
Normal file
38
shared/browser/templates/_types/root/header/_oob_.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{#
|
||||
Shared root header for both base templates and OOB updates
|
||||
|
||||
This macro can be used in two modes:
|
||||
- oob=true: Outputs full div with hx-swap-oob attribute (for OOB updates)
|
||||
- oob=false: Outputs just content, assumes wrapper div exists (for base templates)
|
||||
|
||||
Usage:
|
||||
1. Call root_header_start(oob=true/false)
|
||||
2. Add any section-specific headers
|
||||
3. Call root_header_end(oob=true/false)
|
||||
#}
|
||||
|
||||
{% macro root_header(oob=true) %}
|
||||
{% set select_colours = "
|
||||
[.hover-capable_&]:hover:bg-yellow-300
|
||||
aria-selected:bg-stone-500 aria-selected:text-white
|
||||
[.hover-capable_&[aria-selected=true]:hover]:bg-orange-500
|
||||
"%}
|
||||
|
||||
{% if oob %}
|
||||
<div id="root-header"
|
||||
hx-swap-oob="outerHTML"
|
||||
class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
|
||||
{% endif %}
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row() }}
|
||||
|
||||
{{ caller() }}
|
||||
|
||||
</div>
|
||||
{% if oob %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
84
shared/browser/templates/_types/root/index.html
Normal file
84
shared/browser/templates/_types/root/index.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
{% 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 %}
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
{% block meta %}
|
||||
{% include 'social/meta_site.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% include '_types/root/_head.html' %}
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-900">
|
||||
<div class="max-w-screen-2xl mx-auto py-1 px-1">
|
||||
{% block header %}
|
||||
{% from '_types/root/_n/macros.html' import header with context %}
|
||||
{% call header() %}
|
||||
{% call layout.details('/root-header') %}
|
||||
{% call layout.summary(
|
||||
'root-header-summary',
|
||||
_class='flex items-start gap-2 p-1 + bg-' + menu_colour + '-' + (500-(level()*100))|string,
|
||||
)
|
||||
%}
|
||||
<div class="flex flex-col w-full items-center">
|
||||
|
||||
{% from '_types/root/header/_header.html' import header_row with context %}
|
||||
{{ header_row() }}
|
||||
<div id="root-header-child" class="flex flex-col w-full items-center">
|
||||
{% block root_header_child %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endcall %}
|
||||
{% call layout.menu('root-menu', 'md:hidden bg-yellow-100') %}
|
||||
{% block main_mobile_menu %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
|
||||
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
<div
|
||||
id="filter"
|
||||
>
|
||||
{% block filter %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<main
|
||||
id="root-panel"
|
||||
class="max-w-full">
|
||||
<div class="md:min-h-0">
|
||||
<div class="flex flex-row md:h-full md:min-h-0">
|
||||
<aside
|
||||
id="aside"
|
||||
class="hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
>
|
||||
{% block aside %}
|
||||
{% endblock %}
|
||||
</aside>
|
||||
|
||||
<section
|
||||
id="main-panel"
|
||||
class="flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
<div class="pb-8"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
<script src="{{asset_url('scripts/body.js')}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
shared/browser/templates/_types/root/mobile/_full_user.html
Normal file
10
shared/browser/templates/_types/root/mobile/_full_user.html
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
{% set href=account_url('/') %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
data-close-details
|
||||
>
|
||||
<i class="fa-solid fa-user"></i>
|
||||
<span>{{g.user.email}}</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
<a
|
||||
href="{{ account_url('/') }}"
|
||||
aria-selected="{{ 'true' if '/auth/login' in request.path else 'false' }}"
|
||||
>
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>sign in or register</span>
|
||||
</a>
|
||||
21
shared/browser/templates/macros/admin_nav.html
Normal file
21
shared/browser/templates/macros/admin_nav.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{#
|
||||
Shared admin navigation macro
|
||||
Use this instead of duplicate _nav.html files
|
||||
#}
|
||||
|
||||
{% macro admin_nav_item(href, icon='cog', label='', select_colours='', aclass=styles.nav_button) %}
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(href, hx_select_search, select_colours, True, aclass=aclass) %}
|
||||
<i class="fa fa-{{ icon }}" aria-hidden="true"></i>
|
||||
{{ label }}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro placeholder_nav() %}
|
||||
{# Placeholder for admin sections without specific nav items #}
|
||||
<div class="relative nav-group">
|
||||
<span class="block px-3 py-2 text-stone-400 text-sm italic">
|
||||
Admin options
|
||||
</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
31
shared/browser/templates/macros/cart_icon.html
Normal file
31
shared/browser/templates/macros/cart_icon.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{# Cart icon/badge — shows logo when empty, cart icon with count when items present #}
|
||||
|
||||
{% macro cart_icon(count=0, oob=False) %}
|
||||
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %}>
|
||||
{% if count == 0 %}
|
||||
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||
<a
|
||||
href="{{ blog_url('/') }}"
|
||||
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||
>
|
||||
<img
|
||||
src="{{ site().logo }}"
|
||||
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<a
|
||||
href="{{ cart_url('/') }}"
|
||||
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||
>
|
||||
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
|
||||
<span
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
17
shared/browser/templates/macros/glyphs.html
Normal file
17
shared/browser/templates/macros/glyphs.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% macro opener(group=False) %}
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform group-open{{ '/' + group if group else ''}}:rotate-180"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 9l6 6 6-6"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{% endmacro %}
|
||||
61
shared/browser/templates/macros/layout.html
Normal file
61
shared/browser/templates/macros/layout.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{# templates/macros/layout.html #}
|
||||
|
||||
{% macro details(group = '', _class='') %}
|
||||
<details
|
||||
class="group{{group}} p-2 {{_class}}" data-toggle-group="mobile-panels">
|
||||
{{ caller() }}
|
||||
</details>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro summary(id, _class=None, oob=False) %}
|
||||
<summary>
|
||||
<header class="z-50">
|
||||
<div
|
||||
id="{{id}}"
|
||||
{% if oob %}
|
||||
hx-swap-oob="true"
|
||||
{% endif %}
|
||||
class="{{'flex justify-between items-start gap-2' if not _class else _class}}">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</header>
|
||||
</summary>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro filter_summary(id, current_local_href, search, search_count, hx_select, oob=True) %}
|
||||
<summary class="bg-white/90">
|
||||
<div class="flex flex-row items-start">
|
||||
<div>
|
||||
<div class="md:hidden mx-2 bg-stone-200 rounded">
|
||||
<span class="flex items-center justify-center text-stone-600 text-lg h-12 w-12 transition-transform group-open/filter:hidden self-start">
|
||||
<i class="fa-solid fa-filter"></i>
|
||||
</span>
|
||||
<span>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"
|
||||
class="w-12 h-12 rotate-180 transition-transform group-open/filter:block hidden self-start">
|
||||
<path d="M6 9l6 6 6-6" fill="currentColor"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="{{id}}"
|
||||
class="flex-1 md:hidden grid grid-cols-12 items-center gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
{{ caller() }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% from 'macros/search.html' import search_mobile %}
|
||||
{{ search_mobile(current_local_href, search, search_count, hx_select) }}
|
||||
</div>
|
||||
</summary>
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro menu(id, _class="") %}
|
||||
<div id="{{id}}" hx-swap-oob="outerHTML" class="{{_class}}">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
59
shared/browser/templates/macros/links.html
Normal file
59
shared/browser/templates/macros/links.html
Normal file
@@ -0,0 +1,59 @@
|
||||
|
||||
|
||||
{% macro link(url, select, select_colours='', highlight=True, _class='', aclass='') %}
|
||||
{% set href=url|host%}
|
||||
<div class="relative nav-group {{_class}}">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{select}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if (request.path|host).startswith(href) else 'false' }}"
|
||||
{% if aclass %}
|
||||
class="{{aclass}}"
|
||||
{% elif select_colours %}
|
||||
class="whitespace-normal flex gap-2 px-3 py-2 rounded
|
||||
text-center break-words leading-snug
|
||||
bg-stone-200 text-black
|
||||
{{select_colours if highlight else ''}}
|
||||
"
|
||||
{% else %}
|
||||
class="w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
|
||||
{% endif %}
|
||||
>
|
||||
{{ caller() }}
|
||||
</a>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro menu_row(id=False, oob=False) %}
|
||||
<div
|
||||
{% if id %}
|
||||
id="{{id}}"
|
||||
{% endif %}
|
||||
{% if oob %}
|
||||
hx-swap-oob="outerHTML"
|
||||
{% endif %}
|
||||
class="flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-{{menu_colour}}-{{(500-(level()*100))|string}}"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{{level_up()}}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro desktop_nav() %}
|
||||
<nav class="hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0">
|
||||
{{ caller() }}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro admin() %}
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
<div>
|
||||
settings
|
||||
</div>
|
||||
|
||||
{% endmacro %}
|
||||
68
shared/browser/templates/macros/scrolling_menu.html
Normal file
68
shared/browser/templates/macros/scrolling_menu.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{#
|
||||
Scrolling menu macro with arrow navigation
|
||||
|
||||
Creates a horizontally scrollable menu (desktop) or vertically scrollable (mobile)
|
||||
with arrow buttons that appear/hide based on content overflow.
|
||||
|
||||
Parameters:
|
||||
- container_id: Unique ID for the scroll container
|
||||
- items: List of items to iterate over
|
||||
- item_content: Caller block that renders each item (receives 'item' variable)
|
||||
- wrapper_class: Optional additional classes for outer wrapper
|
||||
- container_class: Optional additional classes for scroll container
|
||||
- item_class: Optional additional classes for each item wrapper
|
||||
#}
|
||||
|
||||
{% macro scrolling_menu(container_id, items, wrapper_class='', container_class='', item_class='') %}
|
||||
{% if items %}
|
||||
{# Left scroll arrow - desktop only #}
|
||||
<button
|
||||
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll left"
|
||||
_="on click
|
||||
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
{# Scrollable container #}
|
||||
<div id="{{ container_id }}"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none {{ container_class }}"
|
||||
style="scroll-behavior: smooth;"
|
||||
_="on load or scroll
|
||||
-- Show arrows if content overflows (desktop only)
|
||||
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||
remove .hidden from .scrolling-menu-arrow-{{ container_id }}
|
||||
add .flex to .scrolling-menu-arrow-{{ container_id }}
|
||||
else
|
||||
add .hidden to .scrolling-menu-arrow-{{ container_id }}
|
||||
remove .flex from .scrolling-menu-arrow-{{ container_id }}
|
||||
end">
|
||||
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
|
||||
{% for item in items %}
|
||||
<div class="{{ item_class }}">
|
||||
{{ caller(item) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
{# Right scroll arrow - desktop only #}
|
||||
<button
|
||||
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll right"
|
||||
_="on click
|
||||
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
83
shared/browser/templates/macros/search.html
Normal file
83
shared/browser/templates/macros/search.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{# Shared search input macros for filter UIs #}
|
||||
|
||||
{% macro search_mobile(current_local_href, search, search_count, hx_select) -%}
|
||||
<div
|
||||
id="search-mobile-wrapper"
|
||||
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||
>
|
||||
<input
|
||||
id="search-mobile"
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="search"
|
||||
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
hx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<div
|
||||
id="search-count-mobile"
|
||||
aria-label="search count"
|
||||
{% if not search_count %}
|
||||
class="text-xl text-red-500"
|
||||
{% endif %}
|
||||
>
|
||||
{% if search %}
|
||||
{{search_count}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro search_desktop(current_local_href, search, search_count, hx_select) -%}
|
||||
<div
|
||||
id="search-desktop-wrapper"
|
||||
class="flex flex-row gap-2 items-center"
|
||||
>
|
||||
<input
|
||||
id="search-desktop"
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="search"
|
||||
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
hx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<div
|
||||
id="search-count-desktop"
|
||||
aria-label="search count"
|
||||
{% if not search_count %}
|
||||
class="text-xl text-red-500"
|
||||
{% endif %}
|
||||
>
|
||||
{% if search %}
|
||||
{{search_count}}
|
||||
{% endif %}
|
||||
{{zap_filter}}
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
24
shared/browser/templates/macros/stickers.html
Normal file
24
shared/browser/templates/macros/stickers.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% macro sticker(src, title, enabled, size=40, found=false) -%}
|
||||
|
||||
<span class="relative inline-flex items-center justify-center group"
|
||||
tabindex="0" aria-label="{{ title|capitalize }}">
|
||||
<!-- sticker icon -->
|
||||
<img
|
||||
src="{{ src }}"
|
||||
width="{{size}}" height="{{size}}"
|
||||
alt="{{ title|capitalize }}"
|
||||
title="{{ title|capitalize }}"
|
||||
class="{% if found %}border-2 border-yellow-200 bg-yellow-300{% endif %} {%if enabled %} opacity-100 {% else %} opacity-40 saturate-0 {% endif %}"
|
||||
/>
|
||||
|
||||
<!-- tooltip -->
|
||||
<span role="tooltip"
|
||||
class="pointer-events-none absolute z-50 bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover/tt:block group-focus-visible/tt:block whitespace-nowrap rounded-md bg-stone-900 text-white text-xs px-2 py-1 shadow-lg">
|
||||
{{ title|capitalize if title|lower != 'sugarfree' else 'Sugar' }}
|
||||
<!-- little arrow -->
|
||||
<span class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-stone-900"></span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{%- endmacro -%}
|
||||
|
||||
10
shared/browser/templates/macros/title.html
Normal file
10
shared/browser/templates/macros/title.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% macro title(_class='') %}
|
||||
<a
|
||||
href="{{ blog_url('/') }}"
|
||||
class="{{_class}}"
|
||||
>
|
||||
<h1>
|
||||
{{ site().title }}
|
||||
</h1>
|
||||
</a>
|
||||
{% endmacro %}
|
||||
5
shared/browser/templates/mobile/menu.html
Normal file
5
shared/browser/templates/mobile/menu.html
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
<div class="md:hidden z-40">
|
||||
{% block menu %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
38
shared/browser/templates/oob_elements.html
Normal file
38
shared/browser/templates/oob_elements.html
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
{% block oobs %}
|
||||
{% endblock %}
|
||||
|
||||
<div
|
||||
id="filter"
|
||||
hx-swap-oob="outerHTML"
|
||||
>
|
||||
{% block filter %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<aside
|
||||
id="aside"
|
||||
hx-swap-oob="outerHTML"
|
||||
class="hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
>
|
||||
{% block aside %}
|
||||
{% endblock %}
|
||||
</aside>
|
||||
|
||||
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
|
||||
{% block mobile_menu %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
|
||||
<section
|
||||
id="main-panel"
|
||||
class="flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||
>
|
||||
{% block content %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
</section>
|
||||
9
shared/browser/templates/sentinel/desktop_content.html
Normal file
9
shared/browser/templates/sentinel/desktop_content.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="js-loading text-center text-xs text-stone-400">
|
||||
loading… {{ page }} / {{ total_pages }}
|
||||
</div>
|
||||
|
||||
<div class="js-neterr hidden inset-0 grid place-items-center p-4">
|
||||
<div class="w-full max-w-[360px]">
|
||||
{% include "sentinel/wireless_error.svg" %}
|
||||
</div>
|
||||
</div>
|
||||
11
shared/browser/templates/sentinel/mobile_content.html
Normal file
11
shared/browser/templates/sentinel/mobile_content.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!-- tiny loading text (default) -->
|
||||
<div class="js-loading text-center text-xs text-stone-400">
|
||||
loading… {{ page }} / {{ total_pages }}
|
||||
</div>
|
||||
|
||||
<!-- BIG error panel (hidden by default) -->
|
||||
<div class="js-neterr hidden flex h-full items-center justify-center">
|
||||
<!-- Funky SVG: unplugged cable + pulse -->
|
||||
|
||||
{% include "sentinel/wireless_error.svg" %}
|
||||
</div>
|
||||
20
shared/browser/templates/sentinel/wireless_error.svg
Normal file
20
shared/browser/templates/sentinel/wireless_error.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg fill="#f5a40cff" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg"
|
||||
width="800px" height="800px" viewBox="0 0 862.899 862.9"
|
||||
xml:space="preserve"
|
||||
class="block w-full h-auto max-h-full" preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g>
|
||||
<g>
|
||||
<circle cx="385.6" cy="656.1" r="79.8"/>
|
||||
<path d="M561.7,401c-15.801-10.3-32.601-19.2-50.2-26.6c-39.9-16.9-82.3-25.5-126-25.5c-44.601,0-87.9,8.9-128.6,26.6
|
||||
c-39.3,17-74.3,41.3-104.1,72.2L253.5,545c34.899-36.1,81.8-56,132-56c49,0,95.1,19.1,129.8,53.8l25.4-25.399L493,469.7L561.7,401
|
||||
z"/>
|
||||
<path d="M385.6,267.1c107.601,0,208.9,41.7,285.3,117.4l98.5-99.5c-50-49.5-108.1-88.4-172.699-115.6
|
||||
c-66.9-28.1-138-42.4-211.101-42.4c-73.6,0-145,14.4-212.3,42.9c-65,27.5-123.3,66.8-173.3,116.9l99,99
|
||||
C175.5,309.299,277.3,267.1,385.6,267.1z"/>
|
||||
<polygon points="616.8,402.5 549.7,469.599 639.2,559.099 549.7,648.599 616.8,715.7 706.3,626.2 795.8,715.7 862.899,648.599
|
||||
773.399,559.099 862.899,469.599 795.8,402.5 706.3,492 "/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1009 B |
54
shared/browser/templates/social/meta_base.html
Normal file
54
shared/browser/templates/social/meta_base.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{# social/meta_base.html — common, non-conflicting head tags #}
|
||||
{# Expected context:
|
||||
site: { title, url, logo, default_image, twitter_site, fb_app_id, description? }
|
||||
request: Quart request (for canonical derivation)
|
||||
robots_override: optional string ("index,follow" / "noindex,nofollow")
|
||||
#}
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{# Canonical #}
|
||||
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
|
||||
{% set canonical = (
|
||||
request.url if request and request.url
|
||||
else (_site_url ~ request.path if request and _site_url else _site_url or None)
|
||||
) %}
|
||||
|
||||
{# Robots: allow override; default to index,follow #}
|
||||
<meta name="robots" content="{{ robots_override if robots_override is defined else 'index,follow' }}">
|
||||
|
||||
{# Theme & RSS #}
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
{% if _site_url %}
|
||||
<link rel="alternate" type="application/rss+xml"
|
||||
title="{{ site().title if site and site().title else 'RSS' }}"
|
||||
href="{{ _site_url }}/rss.xml">
|
||||
{% endif %}
|
||||
|
||||
{# JSON-LD: Organization & WebSite are safe on all pages (don't conflict with BlogPosting) #}
|
||||
{% set org_jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": site().title if site and site().title else "",
|
||||
"url": _site_url if _site_url else None,
|
||||
"logo": site().logo if site and site().logo else None
|
||||
} %}
|
||||
<script type="application/ld+json">
|
||||
{{ org_jsonld | tojson }}
|
||||
</script>
|
||||
|
||||
{% set website_jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": site().title if site and site().title else "",
|
||||
"url": _site_url if _site_url else canonical,
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": (_site_url ~ "/search?q={query}") if _site_url else None,
|
||||
"query-input": "required name=query"
|
||||
}
|
||||
} %}
|
||||
<script type="application/ld+json">
|
||||
{{ website_jsonld | tojson }}
|
||||
</script>
|
||||
25
shared/browser/templates/social/meta_site.html
Normal file
25
shared/browser/templates/social/meta_site.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{# social/meta_site.html — generic site/page meta #}
|
||||
{% include 'social/meta_base.html' %}
|
||||
|
||||
{# Title/description (site-level) #}
|
||||
{% set description = site().description or '' %}
|
||||
|
||||
<title>{{ base_title }}</title>
|
||||
{% if description %}<meta name="description" content="{{ description }}">{% endif %}
|
||||
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
|
||||
|
||||
{# Open Graph (website) #}
|
||||
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ base_title }}">
|
||||
{% if description %}<meta property="og:description" content="{{ description }}">{% endif %}
|
||||
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
|
||||
{% if site and site().default_image %}<meta property="og:image" content="{{ site().default_image }}">{% endif %}
|
||||
{% if site and site().fb_app_id %}<meta property="fb:app_id" content="{{ site().fb_app_id }}">{% endif %}
|
||||
|
||||
{# Twitter (website) #}
|
||||
<meta name="twitter:card" content="{{ 'summary_large_image' if site and site().default_image else 'summary' }}">
|
||||
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
|
||||
<meta name="twitter:title" content="{{ base_title }}">
|
||||
{% if description %}<meta name="twitter:description" content="{{ description }}">{% endif %}
|
||||
{% if site and site().default_image %}<meta name="twitter:image" content="{{ site().default_image }}">{% endif %}
|
||||
Reference in New Issue
Block a user