Phase 6: Replace render_template() with s-expression rendering in all GET routes
Migrate ~52 GET route handlers across all 7 services from Jinja render_template() to s-expression component rendering. Each service gets a sexp_components.py with page/oob/cards render functions. - Add per-service sexp_components.py (account, blog, cart, events, federation, market, orders) with full page, OOB, and pagination card rendering - Add shared/sexp/helpers.py with call_url, root_header_html, full_page, oob_page utilities - Update all GET routes to use get_template_context() + render fns - Fix get_template_context() to inject Jinja globals (URL helpers) - Add qs_filter to base_context for sexp filter URL building - Mount sexp_components.py in docker-compose.dev.yml for all services - Import sexp_components in app.py for Hypercorn --reload watching - Fix route_prefix import (shared.utils not shared.infrastructure.urls) - Fix federation choose-username missing actor in context - Fix market page_markets missing post in context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
|
|||||||
@@ -47,14 +47,17 @@ def register(url_prefix="/"):
|
|||||||
@account_bp.get("/")
|
@account_bp.get("/")
|
||||||
async def account():
|
async def account():
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_account_page, render_account_oob
|
||||||
|
|
||||||
if not g.get("user"):
|
if not g.get("user"):
|
||||||
return redirect(login_url("/"))
|
return redirect(login_url("/"))
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/auth/index.html")
|
html = await render_account_page(ctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/auth/_oob_elements.html")
|
html = await render_account_oob(ctx)
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@@ -86,20 +89,14 @@ def register(url_prefix="/"):
|
|||||||
"subscribed": un.subscribed if un else False,
|
"subscribed": un.subscribed if un else False,
|
||||||
})
|
})
|
||||||
|
|
||||||
nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"}
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_newsletters_page, render_newsletters_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_newsletters_page(ctx, newsletter_list)
|
||||||
"_types/auth/index.html",
|
|
||||||
oob=nl_oob,
|
|
||||||
newsletter_list=newsletter_list,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_newsletters_oob(ctx, newsletter_list)
|
||||||
"_types/auth/_oob_elements.html",
|
|
||||||
oob=nl_oob,
|
|
||||||
newsletter_list=newsletter_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@@ -149,20 +146,14 @@ def register(url_prefix="/"):
|
|||||||
if not fragment_html:
|
if not fragment_html:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
w_oob = {**oob, "main": "_types/auth/_fragment_panel.html"}
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_fragment_page, render_fragment_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_fragment_page(ctx, fragment_html)
|
||||||
"_types/auth/index.html",
|
|
||||||
oob=w_oob,
|
|
||||||
page_fragment_html=fragment_html,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_fragment_oob(ctx, fragment_html)
|
||||||
"_types/auth/_oob_elements.html",
|
|
||||||
oob=w_oob,
|
|
||||||
page_fragment_html=fragment_html,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,11 @@ def register(url_prefix="/auth"):
|
|||||||
if g.get("user"):
|
if g.get("user"):
|
||||||
redirect_url = pop_login_redirect_target()
|
redirect_url = pop_login_redirect_target()
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
return await render_template("auth/login.html")
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_login_page
|
||||||
|
ctx = await get_template_context()
|
||||||
|
return await render_login_page(ctx)
|
||||||
|
|
||||||
@rate_limit(
|
@rate_limit(
|
||||||
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||||
@@ -688,8 +692,11 @@ def register(url_prefix="/auth"):
|
|||||||
@auth_bp.get("/device/")
|
@auth_bp.get("/device/")
|
||||||
async def device_form():
|
async def device_form():
|
||||||
"""Browser form where user enters the code displayed in terminal."""
|
"""Browser form where user enters the code displayed in terminal."""
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_device_page
|
||||||
code = request.args.get("code", "")
|
code = request.args.get("code", "")
|
||||||
return await render_template("auth/device.html", code=code)
|
ctx = await get_template_context(code=code)
|
||||||
|
return await render_device_page(ctx)
|
||||||
|
|
||||||
@auth_bp.post("/device")
|
@auth_bp.post("/device")
|
||||||
@auth_bp.post("/device/")
|
@auth_bp.post("/device/")
|
||||||
@@ -739,6 +746,9 @@ def register(url_prefix="/auth"):
|
|||||||
@auth_bp.get("/device/complete/")
|
@auth_bp.get("/device/complete/")
|
||||||
async def device_complete():
|
async def device_complete():
|
||||||
"""Post-login redirect — completes approval after magic link auth."""
|
"""Post-login redirect — completes approval after magic link auth."""
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_device_page, render_device_approved_page
|
||||||
|
|
||||||
device_code = request.args.get("code", "")
|
device_code = request.args.get("code", "")
|
||||||
|
|
||||||
if not device_code:
|
if not device_code:
|
||||||
@@ -750,11 +760,12 @@ def register(url_prefix="/auth"):
|
|||||||
|
|
||||||
ok = await _approve_device(device_code, g.user)
|
ok = await _approve_device(device_code, g.user)
|
||||||
if not ok:
|
if not ok:
|
||||||
return await render_template(
|
ctx = await get_template_context(
|
||||||
"auth/device.html",
|
|
||||||
error="Code expired or already used. Please start the login process again in your terminal.",
|
error="Code expired or already used. Please start the login process again in your terminal.",
|
||||||
), 400
|
)
|
||||||
|
return await render_device_page(ctx), 400
|
||||||
|
|
||||||
return await render_template("auth/device_approved.html")
|
ctx = await get_template_context()
|
||||||
|
return await render_device_approved_page(ctx)
|
||||||
|
|
||||||
return auth_bp
|
return auth_bp
|
||||||
|
|||||||
379
account/sexp_components.py
Normal file
379
account/sexp_components.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""
|
||||||
|
Account service s-expression page components.
|
||||||
|
|
||||||
|
Renders account dashboard, newsletters, fragment pages, login, and device
|
||||||
|
auth pages. Called from route handlers in place of ``render_template()``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shared.sexp.jinja_bridge import sexp
|
||||||
|
from shared.sexp.helpers import (
|
||||||
|
call_url, root_header_html, search_desktop_html,
|
||||||
|
search_mobile_html, full_page, oob_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Header helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _auth_nav_html(ctx: dict) -> str:
|
||||||
|
"""Auth section desktop nav items."""
|
||||||
|
html = sexp(
|
||||||
|
'(~nav-link :href h :label "newsletters" :select-colours sc)',
|
||||||
|
h=call_url(ctx, "account_url", "/newsletters/"),
|
||||||
|
sc=ctx.get("select_colours", ""),
|
||||||
|
)
|
||||||
|
account_nav_html = ctx.get("account_nav_html", "")
|
||||||
|
if account_nav_html:
|
||||||
|
html += account_nav_html
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||||
|
"""Build the account section header row."""
|
||||||
|
return sexp(
|
||||||
|
'(~menu-row :id "auth-row" :level 1 :colour "sky"'
|
||||||
|
' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
|
||||||
|
' :nav-html nh :child-id "auth-header-child" :oob oob)',
|
||||||
|
lh=call_url(ctx, "account_url", "/"),
|
||||||
|
nh=_auth_nav_html(ctx),
|
||||||
|
oob=oob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_nav_mobile_html(ctx: dict) -> str:
|
||||||
|
"""Mobile nav menu for auth section."""
|
||||||
|
html = sexp(
|
||||||
|
'(~nav-link :href h :label "newsletters" :select-colours sc)',
|
||||||
|
h=call_url(ctx, "account_url", "/newsletters/"),
|
||||||
|
sc=ctx.get("select_colours", ""),
|
||||||
|
)
|
||||||
|
account_nav_html = ctx.get("account_nav_html", "")
|
||||||
|
if account_nav_html:
|
||||||
|
html += account_nav_html
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Account dashboard (GET /)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _account_main_panel_html(ctx: dict) -> str:
|
||||||
|
"""Account info panel with user details and logout."""
|
||||||
|
from quart import g
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
user = getattr(g, "user", None)
|
||||||
|
error = ctx.get("error", "")
|
||||||
|
|
||||||
|
parts = ['<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:
|
||||||
|
parts.append(
|
||||||
|
f'<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">{error}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Account header with logout
|
||||||
|
parts.append('<div class="flex items-center justify-between"><div>')
|
||||||
|
parts.append('<h1 class="text-xl font-semibold tracking-tight">Account</h1>')
|
||||||
|
if user:
|
||||||
|
parts.append(f'<p class="text-sm text-stone-500 mt-1">{user.email}</p>')
|
||||||
|
if user.name:
|
||||||
|
parts.append(f'<p class="text-sm text-stone-600">{user.name}</p>')
|
||||||
|
parts.append('</div>')
|
||||||
|
parts.append(
|
||||||
|
f'<form action="/auth/logout/" method="post">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
|
||||||
|
f'<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">'
|
||||||
|
f'<i class="fa-solid fa-right-from-bracket text-xs"></i> Sign out</button></form>'
|
||||||
|
)
|
||||||
|
parts.append('</div>')
|
||||||
|
|
||||||
|
# Labels
|
||||||
|
if user and hasattr(user, "labels") and user.labels:
|
||||||
|
parts.append('<div><h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>')
|
||||||
|
parts.append('<div class="flex flex-wrap gap-2">')
|
||||||
|
for label in user.labels:
|
||||||
|
parts.append(
|
||||||
|
f'<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">'
|
||||||
|
f'{label.name}</span>'
|
||||||
|
)
|
||||||
|
parts.append('</div></div>')
|
||||||
|
|
||||||
|
parts.append('</div></div>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Newsletters (GET /newsletters/)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str:
|
||||||
|
"""Render a single newsletter toggle switch."""
|
||||||
|
nid = un.newsletter_id
|
||||||
|
toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/")
|
||||||
|
if un.subscribed:
|
||||||
|
bg = "bg-emerald-500"
|
||||||
|
translate = "translate-x-6"
|
||||||
|
checked = "true"
|
||||||
|
else:
|
||||||
|
bg = "bg-stone-300"
|
||||||
|
translate = "translate-x-1"
|
||||||
|
checked = "false"
|
||||||
|
return (
|
||||||
|
f'<div id="nl-{nid}" class="flex items-center">'
|
||||||
|
f'<button hx-post="{toggle_url}"'
|
||||||
|
f' hx-headers=\'{{"X-CSRFToken": "{csrf_token}"}}\''
|
||||||
|
f' hx-target="#nl-{nid}" hx-swap="outerHTML"'
|
||||||
|
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
|
||||||
|
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}"'
|
||||||
|
f' role="switch" aria-checked="{checked}">'
|
||||||
|
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}"></span>'
|
||||||
|
f'</button></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str:
|
||||||
|
"""Newsletters management panel."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
account_url_fn = ctx.get("account_url")
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
|
parts = ['<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:
|
||||||
|
parts.append('<div class="divide-y divide-stone-100">')
|
||||||
|
for item in newsletter_list:
|
||||||
|
nl = item["newsletter"]
|
||||||
|
un = item.get("un")
|
||||||
|
parts.append('<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">')
|
||||||
|
parts.append(f'<div class="min-w-0 flex-1"><p class="text-sm font-medium text-stone-800">{nl.name}</p>')
|
||||||
|
if nl.description:
|
||||||
|
parts.append(f'<p class="text-xs text-stone-500 mt-0.5 truncate">{nl.description}</p>')
|
||||||
|
parts.append('</div><div class="ml-4 flex-shrink-0">')
|
||||||
|
|
||||||
|
if un:
|
||||||
|
parts.append(_newsletter_toggle_html(un, account_url_fn, csrf))
|
||||||
|
else:
|
||||||
|
# No subscription yet — show off toggle
|
||||||
|
toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/")
|
||||||
|
parts.append(
|
||||||
|
f'<div id="nl-{nl.id}" class="flex items-center">'
|
||||||
|
f'<button hx-post="{toggle_url}"'
|
||||||
|
f' hx-headers=\'{{"X-CSRFToken": "{csrf}"}}\''
|
||||||
|
f' hx-target="#nl-{nl.id}" hx-swap="outerHTML"'
|
||||||
|
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
|
||||||
|
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"'
|
||||||
|
f' role="switch" aria-checked="false">'
|
||||||
|
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>'
|
||||||
|
f'</button></div>'
|
||||||
|
)
|
||||||
|
parts.append('</div></div>')
|
||||||
|
parts.append('</div>')
|
||||||
|
else:
|
||||||
|
parts.append('<p class="text-sm text-stone-500">No newsletters available.</p>')
|
||||||
|
|
||||||
|
parts.append('</div></div>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth pages (login, device, check_email)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _login_page_content(ctx: dict) -> str:
|
||||||
|
"""Login form content."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
error = ctx.get("error", "")
|
||||||
|
email = ctx.get("email", "")
|
||||||
|
|
||||||
|
parts = ['<div class="py-8 max-w-md mx-auto">',
|
||||||
|
'<h1 class="text-2xl font-bold mb-6">Sign in</h1>']
|
||||||
|
if error:
|
||||||
|
parts.append(
|
||||||
|
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
|
||||||
|
)
|
||||||
|
action = url_for("auth.start_login")
|
||||||
|
parts.append(
|
||||||
|
f'<form method="post" action="{action}" class="space-y-4">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
|
||||||
|
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
|
||||||
|
f'<input type="email" name="email" id="email" value="{email}" required autofocus'
|
||||||
|
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
|
||||||
|
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
|
||||||
|
f'Send magic link</button></form>'
|
||||||
|
)
|
||||||
|
parts.append('</div>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_page_content(ctx: dict) -> str:
|
||||||
|
"""Device authorization form content."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
error = ctx.get("error", "")
|
||||||
|
code = ctx.get("code", "")
|
||||||
|
|
||||||
|
parts = ['<div class="py-8 max-w-md mx-auto">',
|
||||||
|
'<h1 class="text-2xl font-bold mb-6">Authorize device</h1>',
|
||||||
|
'<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>']
|
||||||
|
if error:
|
||||||
|
parts.append(
|
||||||
|
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
|
||||||
|
)
|
||||||
|
action = url_for("auth.device_submit")
|
||||||
|
parts.append(
|
||||||
|
f'<form method="post" action="{action}" class="space-y-4">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
|
||||||
|
f'<div><label for="code" class="block text-sm font-medium mb-1">Device code</label>'
|
||||||
|
f'<input type="text" name="code" id="code" value="{code}" placeholder="XXXX-XXXX"'
|
||||||
|
f' required autofocus maxlength="9" autocomplete="off" spellcheck="false"'
|
||||||
|
f' class="w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
|
||||||
|
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
|
||||||
|
f'Authorize</button></form>'
|
||||||
|
)
|
||||||
|
parts.append('</div>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_approved_content() -> str:
|
||||||
|
"""Device approved success content."""
|
||||||
|
return (
|
||||||
|
'<div class="py-8 max-w-md mx-auto text-center">'
|
||||||
|
'<h1 class="text-2xl font-bold mb-4">Device authorized</h1>'
|
||||||
|
'<p class="text-stone-600">You can close this window and return to your terminal.</p>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Account dashboard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_account_page(ctx: dict) -> str:
|
||||||
|
"""Full page: account dashboard."""
|
||||||
|
main = _account_main_panel_html(ctx)
|
||||||
|
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
|
||||||
|
a=_auth_header_html(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
content_html=main,
|
||||||
|
menu_html=_auth_nav_mobile_html(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
async def render_account_oob(ctx: dict) -> str:
|
||||||
|
"""OOB response for account dashboard."""
|
||||||
|
main = _account_main_panel_html(ctx)
|
||||||
|
|
||||||
|
oobs = (
|
||||||
|
_auth_header_html(ctx, oob=True)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return oob_page(ctx, oobs_html=oobs,
|
||||||
|
content_html=main,
|
||||||
|
menu_html=_auth_nav_mobile_html(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Newsletters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str:
|
||||||
|
"""Full page: newsletters."""
|
||||||
|
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||||
|
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
|
||||||
|
a=_auth_header_html(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
content_html=main,
|
||||||
|
menu_html=_auth_nav_mobile_html(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
|
||||||
|
"""OOB response for newsletters."""
|
||||||
|
main = _newsletters_panel_html(ctx, newsletter_list)
|
||||||
|
|
||||||
|
oobs = (
|
||||||
|
_auth_header_html(ctx, oob=True)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return oob_page(ctx, oobs_html=oobs,
|
||||||
|
content_html=main,
|
||||||
|
menu_html=_auth_nav_mobile_html(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Fragment pages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str:
|
||||||
|
"""Full page: fragment-provided content."""
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))',
|
||||||
|
a=_auth_header_html(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
content_html=page_fragment_html,
|
||||||
|
menu_html=_auth_nav_mobile_html(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str:
|
||||||
|
"""OOB response for fragment pages."""
|
||||||
|
oobs = (
|
||||||
|
_auth_header_html(ctx, oob=True)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return oob_page(ctx, oobs_html=oobs,
|
||||||
|
content_html=page_fragment_html,
|
||||||
|
menu_html=_auth_nav_mobile_html(ctx))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Auth pages (login, device)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_login_page(ctx: dict) -> str:
|
||||||
|
"""Full page: login form."""
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
content_html=_login_page_content(ctx),
|
||||||
|
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||||
|
|
||||||
|
|
||||||
|
async def render_device_page(ctx: dict) -> str:
|
||||||
|
"""Full page: device authorization form."""
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
content_html=_device_page_content(ctx),
|
||||||
|
meta_html='<title>Authorize Device \u2014 Rose Ash</title>')
|
||||||
|
|
||||||
|
|
||||||
|
async def render_device_approved_page(ctx: dict) -> str:
|
||||||
|
"""Full page: device approved."""
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
content_html=_device_approved_content(),
|
||||||
|
meta_html='<title>Device Authorized \u2014 Rose Ash</title>')
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
|
|||||||
@@ -29,27 +29,28 @@ def register(url_prefix):
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def home():
|
async def home():
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_settings_page, render_settings_oob
|
||||||
|
|
||||||
# Determine which template to use based on request type and pagination
|
tctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_settings_page(tctx)
|
||||||
html = await render_template(
|
|
||||||
"_types/root/settings/index.html",
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/root/settings/_oob_elements.html")
|
html = await render_settings_oob(tctx)
|
||||||
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@bp.get("/cache/")
|
@bp.get("/cache/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def cache():
|
async def cache():
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_cache_page, render_cache_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/root/settings/cache/index.html")
|
html = await render_cache_page(tctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/root/settings/cache/_oob_elements.html")
|
html = await render_cache_oob(tctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@bp.post("/cache_clear/")
|
@bp.post("/cache_clear/")
|
||||||
|
|||||||
@@ -57,10 +57,15 @@ def register():
|
|||||||
|
|
||||||
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
ctx = {"groups": groups, "unassigned_tags": unassigned}
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_tag_groups_page, render_tag_groups_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(ctx)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
return await render_template("_types/blog/admin/tag_groups/index.html", **ctx)
|
return await make_response(await render_tag_groups_page(tctx))
|
||||||
else:
|
else:
|
||||||
return await render_template("_types/blog/admin/tag_groups/_oob_elements.html", **ctx)
|
return await make_response(await render_tag_groups_oob(tctx))
|
||||||
|
|
||||||
@bp.post("/")
|
@bp.post("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
@@ -117,10 +122,15 @@ def register():
|
|||||||
"assigned_tag_ids": assigned_tag_ids,
|
"assigned_tag_ids": assigned_tag_ids,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(ctx)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
return await render_template("_types/blog/admin/tag_groups/edit.html", **ctx)
|
return await make_response(await render_tag_group_edit_page(tctx))
|
||||||
else:
|
else:
|
||||||
return await render_template("_types/blog/admin/tag_groups/_edit_oob.html", **ctx)
|
return await make_response(await render_tag_group_edit_oob(tctx))
|
||||||
|
|
||||||
@bp.post("/<int:id>/")
|
@bp.post("/<int:id>/")
|
||||||
@require_admin
|
@require_admin
|
||||||
|
|||||||
@@ -153,10 +153,15 @@ def register(url_prefix, title):
|
|||||||
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
|
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
|
||||||
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_home_page, render_home_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(ctx)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/home/index.html", **ctx)
|
html = await render_home_page(tctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/home/_oob_elements.html", **ctx)
|
html = await render_home_oob(tctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@blogs_bp.get("/index")
|
@blogs_bp.get("/index")
|
||||||
@@ -185,12 +190,17 @@ def register(url_prefix, title):
|
|||||||
"tag_groups": [],
|
"tag_groups": [],
|
||||||
"posts": data.get("pages", []),
|
"posts": data.get("pages", []),
|
||||||
}
|
}
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(context)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/blog/index.html", **context)
|
html = await render_blog_page(tctx)
|
||||||
elif q.page > 1:
|
elif q.page > 1:
|
||||||
html = await render_template("_types/blog/_page_cards.html", **context)
|
html = await render_blog_page_cards(tctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/blog/_oob_elements.html", **context)
|
html = await render_blog_oob(tctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
# Default: posts listing
|
# Default: posts listing
|
||||||
@@ -221,28 +231,33 @@ def register(url_prefix, title):
|
|||||||
"drafts": q.drafts if show_drafts else None,
|
"drafts": q.drafts if show_drafts else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine which template to use based on request type and pagination
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_blog_page, render_blog_oob, render_blog_cards
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(context)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_blog_page(tctx)
|
||||||
html = await render_template("_types/blog/index.html", **context)
|
|
||||||
elif q.page > 1:
|
elif q.page > 1:
|
||||||
# HTMX pagination: just blog cards + sentinel
|
html = await render_blog_cards(tctx)
|
||||||
html = await render_template("_types/blog/_cards.html", **context)
|
|
||||||
else:
|
else:
|
||||||
# HTMX navigation (page 1): main panel + OOB elements
|
html = await render_blog_oob(tctx)
|
||||||
#main_panel = await render_template("_types/blog/_main_panel.html", **context)
|
|
||||||
html = await render_template("_types/blog/_oob_elements.html", **context)
|
|
||||||
#html = oob_elements + main_panel
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@blogs_bp.get("/new/")
|
@blogs_bp.get("/new/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def new_post():
|
async def new_post():
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_new_post_page, render_new_post_oob
|
||||||
|
|
||||||
|
editor_html = await render_template("_types/blog_new/_main_panel.html")
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["editor_html"] = editor_html
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/blog_new/index.html")
|
html = await render_new_post_page(tctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/blog_new/_oob_elements.html")
|
html = await render_new_post_oob(tctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@blogs_bp.post("/new/")
|
@blogs_bp.post("/new/")
|
||||||
@@ -312,10 +327,17 @@ def register(url_prefix, title):
|
|||||||
@blogs_bp.get("/new-page/")
|
@blogs_bp.get("/new-page/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def new_page():
|
async def new_page():
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_new_post_page, render_new_post_oob
|
||||||
|
|
||||||
|
editor_html = await render_template("_types/blog_new/_main_panel.html", is_page=True)
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["editor_html"] = editor_html
|
||||||
|
tctx["is_page"] = True
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/blog_new/index.html", is_page=True)
|
html = await render_new_post_page(tctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/blog_new/_oob_elements.html", is_page=True)
|
html = await render_new_post_oob(tctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@blogs_bp.post("/new-page/")
|
@blogs_bp.post("/new-page/")
|
||||||
|
|||||||
@@ -34,20 +34,15 @@ def register():
|
|||||||
menu_items = await get_all_menu_items(g.s)
|
menu_items = await get_all_menu_items(g.s)
|
||||||
|
|
||||||
|
|
||||||
if not is_htmx_request():
|
from shared.sexp.page import get_template_context
|
||||||
# Normal browser request: full page with layout
|
from sexp_components import render_menu_items_page, render_menu_items_oob
|
||||||
html = await render_template(
|
|
||||||
"_types/menu_items/index.html",
|
|
||||||
menu_items=menu_items,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
|
|
||||||
html = await render_template(
|
|
||||||
"_types/menu_items/_oob_elements.html",
|
|
||||||
menu_items=menu_items,
|
|
||||||
)
|
|
||||||
#html = await render_template("_types/root/settings/_oob_elements.html")
|
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["menu_items"] = menu_items
|
||||||
|
if not is_htmx_request():
|
||||||
|
html = await render_menu_items_page(tctx)
|
||||||
|
else:
|
||||||
|
html = await render_menu_items_oob(tctx)
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -51,13 +51,15 @@ def register():
|
|||||||
"sumup_checkout_prefix": sumup_checkout_prefix,
|
"sumup_checkout_prefix": sumup_checkout_prefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_post_admin_page, render_post_admin_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(ctx)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_post_admin_page(tctx)
|
||||||
html = await render_template("_types/post/admin/index.html", **ctx)
|
|
||||||
else:
|
else:
|
||||||
# HTMX request: main panel + OOB elements
|
html = await render_post_admin_oob(tctx)
|
||||||
html = await render_template("_types/post/admin/_oob_elements.html", **ctx)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@@ -149,14 +151,16 @@ def register():
|
|||||||
@bp.get("/data/")
|
@bp.get("/data/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def data(slug: str):
|
async def data(slug: str):
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_post_data_page, render_post_data_oob
|
||||||
|
|
||||||
|
data_html = await render_template("_types/post_data/_main_panel.html")
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["data_html"] = data_html
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_post_data_page(tctx)
|
||||||
"_types/post_data/index.html",
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_post_data_oob(tctx)
|
||||||
"_types/post_data/_oob_elements.html",
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@@ -266,18 +270,20 @@ def register():
|
|||||||
# Load entries and post for each calendar
|
# Load entries and post for each calendar
|
||||||
for calendar in all_calendars:
|
for calendar in all_calendars:
|
||||||
await g.s.refresh(calendar, ["entries", "post"])
|
await g.s.refresh(calendar, ["entries", "post"])
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_post_entries_page, render_post_entries_oob
|
||||||
|
|
||||||
|
entries_html = await render_template(
|
||||||
|
"_types/post_entries/_main_panel.html",
|
||||||
|
all_calendars=all_calendars,
|
||||||
|
associated_entry_ids=associated_entry_ids,
|
||||||
|
)
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["entries_html"] = entries_html
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_post_entries_page(tctx)
|
||||||
"_types/post_entries/index.html",
|
|
||||||
all_calendars=all_calendars,
|
|
||||||
associated_entry_ids=associated_entry_ids,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_post_entries_oob(tctx)
|
||||||
"_types/post_entries/_oob_elements.html",
|
|
||||||
all_calendars=all_calendars,
|
|
||||||
associated_entry_ids=associated_entry_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@@ -350,18 +356,20 @@ def register():
|
|||||||
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
|
||||||
save_success = request.args.get("saved") == "1"
|
save_success = request.args.get("saved") == "1"
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_post_settings_page, render_post_settings_oob
|
||||||
|
|
||||||
|
settings_html = await render_template(
|
||||||
|
"_types/post_settings/_main_panel.html",
|
||||||
|
ghost_post=ghost_post,
|
||||||
|
save_success=save_success,
|
||||||
|
)
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["settings_html"] = settings_html
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_post_settings_page(tctx)
|
||||||
"_types/post_settings/index.html",
|
|
||||||
ghost_post=ghost_post,
|
|
||||||
save_success=save_success,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_post_settings_oob(tctx)
|
||||||
"_types/post_settings/_oob_elements.html",
|
|
||||||
ghost_post=ghost_post,
|
|
||||||
save_success=save_success,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@@ -451,20 +459,21 @@ def register():
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_post_edit_page, render_post_edit_oob
|
||||||
|
|
||||||
|
edit_html = await render_template(
|
||||||
|
"_types/post_edit/_main_panel.html",
|
||||||
|
ghost_post=ghost_post,
|
||||||
|
save_success=save_success,
|
||||||
|
newsletters=newsletters,
|
||||||
|
)
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["edit_html"] = edit_html
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_post_edit_page(tctx)
|
||||||
"_types/post_edit/index.html",
|
|
||||||
ghost_post=ghost_post,
|
|
||||||
save_success=save_success,
|
|
||||||
newsletters=newsletters,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_post_edit_oob(tctx)
|
||||||
"_types/post_edit/_oob_elements.html",
|
|
||||||
ghost_post=ghost_post,
|
|
||||||
save_success=save_success,
|
|
||||||
newsletters=newsletters,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -114,13 +114,14 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@cache_page(tag="post.post_detail")
|
@cache_page(tag="post.post_detail")
|
||||||
async def post_detail(slug: str):
|
async def post_detail(slug: str):
|
||||||
# Determine which template to use based on request type
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_post_page, render_post_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_post_page(tctx)
|
||||||
html = await render_template("_types/post/index.html")
|
|
||||||
else:
|
else:
|
||||||
# HTMX request: main panel + OOB elements
|
html = await render_post_oob(tctx)
|
||||||
html = await render_template("_types/post/_oob_elements.html")
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -38,18 +38,16 @@ def register():
|
|||||||
snippets = await _visible_snippets(g.s)
|
snippets = await _visible_snippets(g.s)
|
||||||
is_admin = g.rights.get("admin")
|
is_admin = g.rights.get("admin")
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_snippets_page, render_snippets_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["snippets"] = snippets
|
||||||
|
tctx["is_admin"] = is_admin
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_snippets_page(tctx)
|
||||||
"_types/snippets/index.html",
|
|
||||||
snippets=snippets,
|
|
||||||
is_admin=is_admin,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_snippets_oob(tctx)
|
||||||
"_types/snippets/_oob_elements.html",
|
|
||||||
snippets=snippets,
|
|
||||||
is_admin=is_admin,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
1805
blog/sexp_components.py
Normal file
1805
blog/sexp_components.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -14,18 +14,16 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
async def overview():
|
async def overview():
|
||||||
from quart import g
|
from quart import g
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_overview_page, render_overview_oob
|
||||||
|
|
||||||
page_groups = await get_cart_grouped_by_page(g.s)
|
page_groups = await get_cart_grouped_by_page(g.s)
|
||||||
|
ctx = await get_template_context()
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_overview_page(ctx, page_groups)
|
||||||
"_types/cart/overview/index.html",
|
|
||||||
page_groups=page_groups,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_overview_oob(ctx, page_groups)
|
||||||
"_types/cart/overview/_oob_elements.html",
|
|
||||||
page_groups=page_groups,
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -40,10 +40,20 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
ticket_total=ticket_total,
|
ticket_total=ticket_total,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_page_cart_page, render_page_cart_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/cart/page/index.html", **tpl_ctx)
|
html = await render_page_cart_page(
|
||||||
|
ctx, post, cart, cal_entries, page_tickets,
|
||||||
|
ticket_groups, total, calendar_total, ticket_total,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/cart/page/_oob_elements.html", **tpl_ctx)
|
html = await render_page_cart_oob(
|
||||||
|
ctx, post, cart, cal_entries, page_tickets,
|
||||||
|
ticket_groups, total, calendar_total, ticket_total,
|
||||||
|
)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@bp.post("/checkout/")
|
@bp.post("/checkout/")
|
||||||
|
|||||||
@@ -55,12 +55,16 @@ def register() -> Blueprint:
|
|||||||
order = result.scalar_one_or_none()
|
order = result.scalar_one_or_none()
|
||||||
if not order:
|
if not order:
|
||||||
return await make_response("Order not found", 404)
|
return await make_response("Order not found", 404)
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_order_page, render_order_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
calendar_entries = ctx.get("calendar_entries")
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_order_page(ctx, order, calendar_entries, url_for)
|
||||||
html = await render_template("_types/order/index.html", order=order,)
|
|
||||||
else:
|
else:
|
||||||
# HTMX navigation (page 1): main panel + OOB elements
|
html = await render_order_oob(ctx, order, calendar_entries, url_for)
|
||||||
html = await render_template("_types/order/_oob_elements.html", order=order,)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -136,24 +136,30 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
result = await g.s.execute(stmt)
|
result = await g.s.execute(stmt)
|
||||||
orders = result.scalars().all()
|
orders = result.scalars().all()
|
||||||
|
|
||||||
context = {
|
from shared.sexp.page import get_template_context
|
||||||
"orders": orders,
|
from sexp_components import (
|
||||||
"page": page,
|
render_orders_page,
|
||||||
"total_pages": total_pages,
|
render_orders_rows,
|
||||||
"search": search,
|
render_orders_oob,
|
||||||
"search_count": total_count, # For search display
|
)
|
||||||
}
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
qs_fn = makeqs_factory()
|
||||||
|
|
||||||
# Determine which template to use based on request type and pagination
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_orders_page(
|
||||||
html = await render_template("_types/orders/index.html", **context)
|
ctx, orders, page, total_pages, search, total_count,
|
||||||
|
url_for, qs_fn,
|
||||||
|
)
|
||||||
elif page > 1:
|
elif page > 1:
|
||||||
# HTMX pagination: just table rows + sentinel
|
html = await render_orders_rows(
|
||||||
html = await render_template("_types/orders/_rows.html", **context)
|
ctx, orders, page, total_pages, url_for, qs_fn,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# HTMX navigation (page 1): main panel + OOB elements
|
html = await render_orders_oob(
|
||||||
html = await render_template("_types/orders/_oob_elements.html", **context)
|
ctx, orders, page, total_pages, search, total_count,
|
||||||
|
url_for, qs_fn,
|
||||||
|
)
|
||||||
|
|
||||||
resp = await make_response(html)
|
resp = await make_response(html)
|
||||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||||
|
|||||||
805
cart/sexp_components.py
Normal file
805
cart/sexp_components.py
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
"""
|
||||||
|
Cart service s-expression page components.
|
||||||
|
|
||||||
|
Renders cart overview, page cart, orders list, and single order detail.
|
||||||
|
Called from route handlers in place of ``render_template()``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shared.sexp.jinja_bridge import sexp
|
||||||
|
from shared.sexp.helpers import (
|
||||||
|
call_url, root_header_html, search_desktop_html,
|
||||||
|
search_mobile_html, full_page, oob_page,
|
||||||
|
)
|
||||||
|
from shared.infrastructure.urls import market_product_url
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Header helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||||
|
"""Build the cart section header row."""
|
||||||
|
return sexp(
|
||||||
|
'(~menu-row :id "cart-row" :level 1 :colour "sky"'
|
||||||
|
' :link-href lh :link-label "cart" :icon "fa fa-shopping-cart"'
|
||||||
|
' :child-id "cart-header-child" :oob oob)',
|
||||||
|
lh=call_url(ctx, "cart_url", "/"),
|
||||||
|
oob=oob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||||
|
"""Build the per-page cart header row."""
|
||||||
|
slug = page_post.slug if page_post else ""
|
||||||
|
title = (page_post.title or "")[:160]
|
||||||
|
img_html = ""
|
||||||
|
if page_post and page_post.feature_image:
|
||||||
|
img_html = (
|
||||||
|
f'<img src="{page_post.feature_image}"'
|
||||||
|
f' class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
|
||||||
|
)
|
||||||
|
label_html = f'{img_html}<span>{title}</span>'
|
||||||
|
nav_html = sexp(
|
||||||
|
'(a :href h :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"'
|
||||||
|
' (raw! i) "All carts")',
|
||||||
|
h=call_url(ctx, "cart_url", "/"),
|
||||||
|
i='<i class="fa fa-arrow-left text-xs" aria-hidden="true"></i>',
|
||||||
|
)
|
||||||
|
return sexp(
|
||||||
|
'(~menu-row :id "page-cart-row" :level 2 :colour "sky"'
|
||||||
|
' :link-href lh :link-label-html llh :nav-html nh :oob oob)',
|
||||||
|
lh=call_url(ctx, "cart_url", f"/{slug}/"),
|
||||||
|
llh=label_html,
|
||||||
|
nh=nav_html,
|
||||||
|
oob=oob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||||
|
"""Build the account section header row (for orders)."""
|
||||||
|
return sexp(
|
||||||
|
'(~menu-row :id "auth-row" :level 1 :colour "sky"'
|
||||||
|
' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
|
||||||
|
' :child-id "auth-header-child" :oob oob)',
|
||||||
|
lh=call_url(ctx, "account_url", "/"),
|
||||||
|
oob=oob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_header_html(ctx: dict, list_url: str) -> str:
|
||||||
|
"""Build the orders section header row."""
|
||||||
|
return sexp(
|
||||||
|
'(~menu-row :id "orders-row" :level 2 :colour "sky"'
|
||||||
|
' :link-href lh :link-label "Orders" :icon "fa fa-gbp"'
|
||||||
|
' :child-id "orders-header-child")',
|
||||||
|
lh=list_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cart overview
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _page_group_card_html(grp: Any, ctx: dict) -> str:
|
||||||
|
"""Render a single page group card for cart overview."""
|
||||||
|
post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None)
|
||||||
|
cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", [])
|
||||||
|
cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", [])
|
||||||
|
tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", [])
|
||||||
|
product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0)
|
||||||
|
calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0)
|
||||||
|
ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0)
|
||||||
|
total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0)
|
||||||
|
market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None)
|
||||||
|
|
||||||
|
if not cart_items and not cal_entries and not tickets:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Count badges
|
||||||
|
badges = []
|
||||||
|
if product_count > 0:
|
||||||
|
s = "s" if product_count != 1 else ""
|
||||||
|
badges.append(
|
||||||
|
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
|
||||||
|
f'<i class="fa fa-box-open" aria-hidden="true"></i> {product_count} item{s}</span>'
|
||||||
|
)
|
||||||
|
if calendar_count > 0:
|
||||||
|
s = "s" if calendar_count != 1 else ""
|
||||||
|
badges.append(
|
||||||
|
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
|
||||||
|
f'<i class="fa fa-calendar" aria-hidden="true"></i> {calendar_count} booking{s}</span>'
|
||||||
|
)
|
||||||
|
if ticket_count > 0:
|
||||||
|
s = "s" if ticket_count != 1 else ""
|
||||||
|
badges.append(
|
||||||
|
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
|
||||||
|
f'<i class="fa fa-ticket" aria-hidden="true"></i> {ticket_count} ticket{s}</span>'
|
||||||
|
)
|
||||||
|
badges_html = '<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">' + "".join(badges) + '</div>'
|
||||||
|
|
||||||
|
if post:
|
||||||
|
slug = post.slug if hasattr(post, "slug") else post.get("slug", "")
|
||||||
|
title = post.title if hasattr(post, "title") else post.get("title", "")
|
||||||
|
feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image")
|
||||||
|
cart_url = call_url(ctx, "cart_url", f"/{slug}/")
|
||||||
|
|
||||||
|
if feature_image:
|
||||||
|
img = f'<img src="{feature_image}" alt="{title}" class="h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0">'
|
||||||
|
else:
|
||||||
|
img = '<div class="h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"><i class="fa fa-store text-stone-400 text-xl" aria-hidden="true"></i></div>'
|
||||||
|
|
||||||
|
mp_name = ""
|
||||||
|
mp_sub = ""
|
||||||
|
if market_place:
|
||||||
|
mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "")
|
||||||
|
mp_sub = f'<p class="text-xs text-stone-500 truncate">{title}</p>'
|
||||||
|
display_title = mp_name or title
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<a href="{cart_url}" class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5">'
|
||||||
|
f'<div class="flex items-start gap-4">{img}'
|
||||||
|
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">{display_title}</h3>{mp_sub}{badges_html}</div>'
|
||||||
|
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">£{total:.2f}</div>'
|
||||||
|
f'<div class="mt-1 text-xs text-emerald-700 font-medium">View cart →</div></div></div></a>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Orphan items
|
||||||
|
badges_html_amber = badges_html.replace("bg-stone-100", "bg-amber-100")
|
||||||
|
return (
|
||||||
|
f'<div class="rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5">'
|
||||||
|
f'<div class="flex items-start gap-4">'
|
||||||
|
f'<div class="h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0">'
|
||||||
|
f'<i class="fa fa-shopping-cart text-amber-500 text-xl" aria-hidden="true"></i></div>'
|
||||||
|
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900">Other items</h3>{badges_html_amber}</div>'
|
||||||
|
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">£{total:.2f}</div></div></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
|
||||||
|
"""Cart overview main panel."""
|
||||||
|
if not page_groups:
|
||||||
|
return (
|
||||||
|
'<div class="max-w-full px-3 py-3 space-y-3">'
|
||||||
|
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">'
|
||||||
|
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
|
||||||
|
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
|
||||||
|
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
cards = [_page_group_card_html(grp, ctx) for grp in page_groups]
|
||||||
|
has_items = any(c for c in cards)
|
||||||
|
if not has_items:
|
||||||
|
return (
|
||||||
|
'<div class="max-w-full px-3 py-3 space-y-3">'
|
||||||
|
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">'
|
||||||
|
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
|
||||||
|
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
|
||||||
|
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return '<div class="max-w-full px-3 py-3 space-y-3"><div class="space-y-4">' + "".join(cards) + '</div></div>'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page cart
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cart_item_html(item: Any, ctx: dict) -> str:
|
||||||
|
"""Render a single product cart item."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
p = item.product if hasattr(item, "product") else item
|
||||||
|
slug = p.slug if hasattr(p, "slug") else ""
|
||||||
|
unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None)
|
||||||
|
currency = getattr(p, "regular_price_currency", "GBP") or "GBP"
|
||||||
|
symbol = "\u00a3" if currency == "GBP" else currency
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
qty_url = url_for("cart_global.update_quantity", product_id=p.id)
|
||||||
|
prod_url = market_product_url(slug)
|
||||||
|
|
||||||
|
if p.image:
|
||||||
|
img = f'<img src="{p.image}" alt="{p.title}" class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" loading="lazy">'
|
||||||
|
else:
|
||||||
|
img = '<div class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400">No image</div>'
|
||||||
|
|
||||||
|
price_html = ""
|
||||||
|
if unit_price:
|
||||||
|
price_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">{symbol}{unit_price:.2f}</p>'
|
||||||
|
if p.special_price and p.special_price != p.regular_price:
|
||||||
|
price_html += f'<p class="text-xs text-stone-400 line-through">{symbol}{p.regular_price:.2f}</p>'
|
||||||
|
else:
|
||||||
|
price_html = '<p class="text-xs text-stone-500">No price</p>'
|
||||||
|
|
||||||
|
deleted_html = ""
|
||||||
|
if getattr(item, "is_deleted", False):
|
||||||
|
deleted_html = (
|
||||||
|
'<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">'
|
||||||
|
'<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>'
|
||||||
|
' This item is no longer available or price has changed</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
brand_html = f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{p.brand}</p>' if getattr(p, "brand", None) else ""
|
||||||
|
|
||||||
|
line_total_html = ""
|
||||||
|
if unit_price:
|
||||||
|
lt = unit_price * item.quantity
|
||||||
|
line_total_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: {symbol}{lt:.2f}</p>'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<article id="cart-item-{slug}" class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5">'
|
||||||
|
f'<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">{img}</div>'
|
||||||
|
f'<div class="flex-1 min-w-0">'
|
||||||
|
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
|
||||||
|
f'<div class="min-w-0"><h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">'
|
||||||
|
f'<a href="{prod_url}" class="hover:text-emerald-700">{p.title}</a></h2>{brand_html}{deleted_html}</div>'
|
||||||
|
f'<div class="text-left sm:text-right">{price_html}</div></div>'
|
||||||
|
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
|
||||||
|
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
|
||||||
|
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
|
||||||
|
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity - 1}">'
|
||||||
|
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
|
||||||
|
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{item.quantity}</span>'
|
||||||
|
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity + 1}">'
|
||||||
|
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
|
||||||
|
f'<div class="flex items-center justify-between sm:justify-end gap-3">{line_total_html}</div></div></div></article>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_entries_html(entries: list) -> str:
|
||||||
|
"""Render calendar booking entries in cart."""
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
items = []
|
||||||
|
for e in entries:
|
||||||
|
name = getattr(e, "name", None) or getattr(e, "calendar_name", "")
|
||||||
|
start = e.start_at if hasattr(e, "start_at") else ""
|
||||||
|
end = getattr(e, "end_at", None)
|
||||||
|
cost = getattr(e, "cost", 0) or 0
|
||||||
|
end_html = f" \u2013 {end}" if end else ""
|
||||||
|
items.append(
|
||||||
|
f'<li class="flex items-start justify-between text-sm">'
|
||||||
|
f'<div><div class="font-medium">{name}</div>'
|
||||||
|
f'<div class="text-xs text-stone-500">{start}{end_html}</div></div>'
|
||||||
|
f'<div class="ml-4 font-medium">\u00a3{cost:.2f}</div></li>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<div class="mt-6 border-t border-stone-200 pt-4">'
|
||||||
|
'<h2 class="text-base font-semibold mb-2">Calendar bookings</h2>'
|
||||||
|
f'<ul class="space-y-2">{"".join(items)}</ul></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str:
|
||||||
|
"""Render ticket groups in cart."""
|
||||||
|
if not ticket_groups:
|
||||||
|
return ""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
qty_url = url_for("cart_global.update_ticket_quantity")
|
||||||
|
parts = ['<div class="mt-6 border-t border-stone-200 pt-4">',
|
||||||
|
'<h2 class="text-base font-semibold mb-2"><i class="fa fa-ticket mr-1" aria-hidden="true"></i> Event tickets</h2>',
|
||||||
|
'<div class="space-y-3">']
|
||||||
|
|
||||||
|
for tg in ticket_groups:
|
||||||
|
name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "")
|
||||||
|
tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "")
|
||||||
|
price = tg.price if hasattr(tg, "price") else tg.get("price", 0)
|
||||||
|
quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0)
|
||||||
|
line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0)
|
||||||
|
entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "")
|
||||||
|
tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "")
|
||||||
|
start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at")
|
||||||
|
end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at")
|
||||||
|
|
||||||
|
date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else ""
|
||||||
|
if end_at:
|
||||||
|
date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||||
|
|
||||||
|
tt_name_html = f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{tt_name}</p>' if tt_name else ""
|
||||||
|
tt_hidden = f'<input type="hidden" name="ticket_type_id" value="{tt_id}">' if tt_id else ""
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
f'<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">'
|
||||||
|
f'<div class="flex-1 min-w-0">'
|
||||||
|
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
|
||||||
|
f'<div class="min-w-0"><h3 class="text-sm sm:text-base font-semibold text-stone-900">{name}</h3>{tt_name_html}'
|
||||||
|
f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{date_str}</p></div>'
|
||||||
|
f'<div class="text-left sm:text-right"><p class="text-sm sm:text-base font-semibold text-stone-900">\u00a3{price or 0:.2f}</p></div></div>'
|
||||||
|
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
|
||||||
|
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
|
||||||
|
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
|
||||||
|
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
|
||||||
|
f'<input type="hidden" name="count" value="{max(quantity - 1, 0)}">'
|
||||||
|
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
|
||||||
|
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{quantity}</span>'
|
||||||
|
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
|
||||||
|
f'<input type="hidden" name="count" value="{quantity + 1}">'
|
||||||
|
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
|
||||||
|
f'<div class="flex items-center justify-between sm:justify-end gap-3">'
|
||||||
|
f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: \u00a3{line_total:.2f}</p></div></div></div></article>'
|
||||||
|
)
|
||||||
|
|
||||||
|
parts.append('</div></div>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list,
|
||||||
|
total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||||
|
"""Render the order summary sidebar."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import g, url_for, request
|
||||||
|
from shared.infrastructure.urls import login_url
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
product_qty = sum(ci.quantity for ci in cart) if cart else 0
|
||||||
|
ticket_qty = len(tickets) if tickets else 0
|
||||||
|
item_count = product_qty + ticket_qty
|
||||||
|
|
||||||
|
product_total = total_fn(cart) or 0
|
||||||
|
cal_total = cal_total_fn(cal_entries) or 0
|
||||||
|
tk_total = ticket_total_fn(tickets) or 0
|
||||||
|
grand = float(product_total) + float(cal_total) + float(tk_total)
|
||||||
|
|
||||||
|
symbol = "\u00a3"
|
||||||
|
if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None):
|
||||||
|
cur = cart[0].product.regular_price_currency
|
||||||
|
symbol = "\u00a3" if cur == "GBP" else cur
|
||||||
|
|
||||||
|
user = getattr(g, "user", None)
|
||||||
|
page_post = ctx.get("page_post")
|
||||||
|
|
||||||
|
if user:
|
||||||
|
if page_post:
|
||||||
|
action = url_for("page_cart.page_checkout")
|
||||||
|
else:
|
||||||
|
action = url_for("cart_global.checkout")
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
action = route_prefix() + action
|
||||||
|
checkout_html = (
|
||||||
|
f'<form method="post" action="{action}" class="w-full">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<button type="submit" class="w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
|
||||||
|
f'<i class="fa-solid fa-credit-card mr-2" aria-hidden="true"></i> Checkout as {user.email}</button></form>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
href = login_url(request.url)
|
||||||
|
checkout_html = (
|
||||||
|
f'<div class="w-full flex"><a href="{href}" class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition">'
|
||||||
|
f'<i class="fa-solid fa-key"></i><span>sign in or register to checkout</span></a></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<aside id="cart-summary" class="lg:pl-2"><div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">'
|
||||||
|
f'<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">Order summary</h2>'
|
||||||
|
f'<dl class="space-y-2 text-xs sm:text-sm">'
|
||||||
|
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Items</dt><dd class="text-stone-900">{item_count}</dd></div>'
|
||||||
|
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Subtotal</dt><dd class="text-stone-900">{symbol}{grand:.2f}</dd></div></dl>'
|
||||||
|
f'<div class="flex flex-col items-center w-full"><h1 class="text-5xl mt-2">This is a test - it will not take actual money</h1>'
|
||||||
|
f'<div>use dummy card number: 5555 5555 5555 4444</div></div>'
|
||||||
|
f'<div class="mt-4 sm:mt-5">{checkout_html}</div></div></aside>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list,
|
||||||
|
tickets: list, ticket_groups: list,
|
||||||
|
total_fn: Any, cal_total_fn: Any,
|
||||||
|
ticket_total_fn: Any) -> str:
|
||||||
|
"""Page cart main panel."""
|
||||||
|
if not cart and not cal_entries and not tickets:
|
||||||
|
return (
|
||||||
|
'<div class="max-w-full px-3 py-3 space-y-3">'
|
||||||
|
'<div id="cart"><div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">'
|
||||||
|
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
|
||||||
|
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
|
||||||
|
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
items_html = "".join(_cart_item_html(item, ctx) for item in cart)
|
||||||
|
cal_html = _calendar_entries_html(cal_entries)
|
||||||
|
tickets_html = _ticket_groups_html(ticket_groups, ctx)
|
||||||
|
summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<div class="max-w-full px-3 py-3 space-y-3"><div id="cart">'
|
||||||
|
f'<div><section class="space-y-3 sm:space-y-4">{items_html}{cal_html}{tickets_html}</section>'
|
||||||
|
f'{summary_html}</div></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orders list (same pattern as orders service)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _order_row_html(order: Any, detail_url: str) -> str:
|
||||||
|
"""Render a single order as desktop table row + mobile card."""
|
||||||
|
status = order.status or "pending"
|
||||||
|
sl = status.lower()
|
||||||
|
pill = (
|
||||||
|
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
|
||||||
|
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
|
||||||
|
else "border-stone-300 bg-stone-50 text-stone-700"
|
||||||
|
)
|
||||||
|
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||||
|
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">'
|
||||||
|
f'<td class="px-3 py-2 align-top"><span class="font-mono text-[11px] sm:text-xs">#{order.id}</span></td>'
|
||||||
|
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{created}</td>'
|
||||||
|
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{order.description or ""}</td>'
|
||||||
|
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{total}</td>'
|
||||||
|
f'<td class="px-3 py-2 align-top"><span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}">{status}</span></td>'
|
||||||
|
f'<td class="px-3 py-0.5 align-top text-right"><a href="{detail_url}" class="inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">View</a></td></tr>'
|
||||||
|
f'<tr class="sm:hidden border-t border-stone-100"><td colspan="5" class="px-3 py-3"><div class="flex flex-col gap-2 text-xs">'
|
||||||
|
f'<div class="flex items-center justify-between gap-2"><span class="font-mono text-[11px] text-stone-700">#{order.id}</span>'
|
||||||
|
f'<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}">{status}</span></div>'
|
||||||
|
f'<div class="text-[11px] text-stone-500 break-words">{created}</div>'
|
||||||
|
f'<div class="flex items-center justify-between gap-2"><div class="font-medium text-stone-800">{total}</div>'
|
||||||
|
f'<a href="{detail_url}" class="inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0">View</a></div></div></td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_rows_html(orders: list, page: int, total_pages: int,
|
||||||
|
url_for_fn: Any, qs_fn: Any) -> str:
|
||||||
|
"""Render order rows + infinite scroll sentinel."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
pfx = route_prefix()
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
_order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
|
||||||
|
for o in orders
|
||||||
|
]
|
||||||
|
|
||||||
|
if page < total_pages:
|
||||||
|
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
|
||||||
|
parts.append(sexp(
|
||||||
|
'(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)',
|
||||||
|
u=next_url, p=page, **{"total-pages": total_pages},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
parts.append('<tr><td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">End of results</td></tr>')
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
|
||||||
|
"""Main panel for orders list."""
|
||||||
|
if not orders:
|
||||||
|
return (
|
||||||
|
'<div class="max-w-full px-3 py-3 space-y-3">'
|
||||||
|
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700">'
|
||||||
|
'No orders yet.</div></div>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<div class="max-w-full px-3 py-3 space-y-3">'
|
||||||
|
'<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">'
|
||||||
|
'<table class="min-w-full text-xs sm:text-sm">'
|
||||||
|
'<thead class="bg-stone-50 border-b border-stone-200 text-stone-600"><tr>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Order</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Created</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Description</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Total</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Status</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium"></th>'
|
||||||
|
f'</tr></thead><tbody>{rows_html}</tbody></table></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_summary_html(ctx: dict) -> str:
|
||||||
|
"""Filter section for orders list."""
|
||||||
|
return (
|
||||||
|
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
|
||||||
|
'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Recent orders placed via the checkout.</p></div>'
|
||||||
|
f'<div class="md:hidden">{search_mobile_html(ctx)}</div>'
|
||||||
|
'</header>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Single order detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _order_items_html(order: Any) -> str:
|
||||||
|
"""Render order items list."""
|
||||||
|
if not order or not order.items:
|
||||||
|
return ""
|
||||||
|
items = []
|
||||||
|
for item in order.items:
|
||||||
|
prod_url = market_product_url(item.product_slug)
|
||||||
|
img = (
|
||||||
|
f'<img src="{item.product_image}" alt="{item.product_title or "Product image"}"'
|
||||||
|
f' class="w-full h-full object-contain object-center" loading="lazy" decoding="async">'
|
||||||
|
if item.product_image else
|
||||||
|
'<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">No image</div>'
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
f'<li><a class="w-full py-2 flex gap-3" href="{prod_url}">'
|
||||||
|
f'<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">{img}</div>'
|
||||||
|
f'<div class="flex-1 flex justify-between gap-3">'
|
||||||
|
f'<div><p class="font-medium">{item.product_title or "Unknown product"}</p>'
|
||||||
|
f'<p class="text-[11px] text-stone-500">Product ID: {item.product_id}</p></div>'
|
||||||
|
f'<div class="text-right whitespace-nowrap"><p>Qty: {item.quantity}</p>'
|
||||||
|
f'<p>{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}</p>'
|
||||||
|
f'</div></div></a></li>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">'
|
||||||
|
'<h2 class="text-sm sm:text-base font-semibold mb-3">Items</h2>'
|
||||||
|
f'<ul class="divide-y divide-stone-100 text-xs sm:text-sm">{"".join(items)}</ul></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _order_summary_html(order: Any) -> str:
|
||||||
|
"""Order summary card."""
|
||||||
|
return sexp(
|
||||||
|
'(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)',
|
||||||
|
oid=order.id,
|
||||||
|
ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
|
||||||
|
d=order.description, s=order.status, c=order.currency,
|
||||||
|
ta=f"{order.total_amount:.2f}" if order.total_amount else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _order_calendar_items_html(calendar_entries: list | None) -> str:
|
||||||
|
"""Render calendar bookings for an order."""
|
||||||
|
if not calendar_entries:
|
||||||
|
return ""
|
||||||
|
items = []
|
||||||
|
for e in calendar_entries:
|
||||||
|
st = e.state or ""
|
||||||
|
pill = (
|
||||||
|
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||||
|
else "bg-amber-100 text-amber-800" if st == "provisional"
|
||||||
|
else "bg-blue-100 text-blue-800" if st == "ordered"
|
||||||
|
else "bg-stone-100 text-stone-700"
|
||||||
|
)
|
||||||
|
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||||
|
if e.end_at:
|
||||||
|
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||||
|
items.append(
|
||||||
|
f'<li class="px-4 py-3 flex items-start justify-between text-sm">'
|
||||||
|
f'<div><div class="font-medium flex items-center gap-2">{e.name}'
|
||||||
|
f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}">'
|
||||||
|
f'{st.capitalize()}</span></div>'
|
||||||
|
f'<div class="text-xs text-stone-500">{ds}</div></div>'
|
||||||
|
f'<div class="ml-4 font-medium">\u00a3{e.cost or 0:.2f}</div></li>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<section class="mt-6 space-y-3">'
|
||||||
|
'<h2 class="text-base sm:text-lg font-semibold">Calendar bookings in this order</h2>'
|
||||||
|
f'<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">{"".join(items)}</ul></section>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _order_main_html(order: Any, calendar_entries: list | None) -> str:
|
||||||
|
"""Main panel for single order detail."""
|
||||||
|
summary = _order_summary_html(order)
|
||||||
|
return f'<div class="max-w-full px-3 py-3 space-y-4">{summary}{_order_items_html(order)}{_order_calendar_items_html(calendar_entries)}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _order_filter_html(order: Any, list_url: str, recheck_url: str,
|
||||||
|
pay_url: str, csrf_token: str) -> str:
|
||||||
|
"""Filter section for single order detail."""
|
||||||
|
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||||
|
status = order.status or "pending"
|
||||||
|
pay = (
|
||||||
|
f'<a href="{pay_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm '
|
||||||
|
f'rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
|
||||||
|
f'<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>Open payment page</a>'
|
||||||
|
) if status != "paid" else ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
|
||||||
|
f'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Placed {created} · Status: {status}</p></div>'
|
||||||
|
'<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">'
|
||||||
|
f'<a href="{list_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-list mr-2" aria-hidden="true"></i>All orders</a>'
|
||||||
|
f'<form method="post" action="{recheck_url}" class="inline"><input type="hidden" name="csrf_token" value="{csrf_token}">'
|
||||||
|
f'<button type="submit" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>Re-check status</button></form>'
|
||||||
|
f'{pay}</div></header>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Cart overview
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_overview_page(ctx: dict, page_groups: list) -> str:
|
||||||
|
"""Full page: cart overview."""
|
||||||
|
main = _overview_main_panel_html(page_groups, ctx)
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))',
|
||||||
|
c=_cart_header_html(ctx),
|
||||||
|
)
|
||||||
|
return full_page(ctx, header_rows_html=hdr, content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_overview_oob(ctx: dict, page_groups: list) -> str:
|
||||||
|
"""OOB response for cart overview."""
|
||||||
|
main = _overview_main_panel_html(page_groups, ctx)
|
||||||
|
oobs = (
|
||||||
|
_cart_header_html(ctx, oob=True)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
return oob_page(ctx, oobs_html=oobs, content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Page cart
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_page_cart_page(ctx: dict, page_post: Any,
|
||||||
|
cart: list, cal_entries: list, tickets: list,
|
||||||
|
ticket_groups: list, total_fn: Any,
|
||||||
|
cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||||
|
"""Full page: page-specific cart."""
|
||||||
|
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
|
||||||
|
total_fn, cal_total_fn, ticket_total_fn)
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
child = _cart_header_html(ctx)
|
||||||
|
page_hdr = _page_cart_header_html(ctx, page_post)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c)'
|
||||||
|
' (div :id "cart-header-child" :class "flex flex-col w-full items-center" (raw! p)))',
|
||||||
|
c=child, p=page_hdr,
|
||||||
|
)
|
||||||
|
return full_page(ctx, header_rows_html=hdr, content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_page_cart_oob(ctx: dict, page_post: Any,
|
||||||
|
cart: list, cal_entries: list, tickets: list,
|
||||||
|
ticket_groups: list, total_fn: Any,
|
||||||
|
cal_total_fn: Any, ticket_total_fn: Any) -> str:
|
||||||
|
"""OOB response for page cart."""
|
||||||
|
main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups,
|
||||||
|
total_fn, cal_total_fn, ticket_total_fn)
|
||||||
|
oobs = (
|
||||||
|
sexp('(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! p))',
|
||||||
|
p=_page_cart_header_html(ctx, page_post))
|
||||||
|
+ _cart_header_html(ctx, oob=True)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
return oob_page(ctx, oobs_html=oobs, content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Orders list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_orders_page(ctx: dict, orders: list, page: int,
|
||||||
|
total_pages: int, search: str | None,
|
||||||
|
search_count: int, url_for_fn: Any,
|
||||||
|
qs_fn: Any) -> str:
|
||||||
|
"""Full page: orders list."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
|
||||||
|
ctx["search"] = search
|
||||||
|
ctx["search_count"] = search_count
|
||||||
|
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||||
|
|
||||||
|
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||||
|
main = _orders_main_panel_html(orders, rows)
|
||||||
|
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
|
||||||
|
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! o)))',
|
||||||
|
a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
filter_html=_orders_summary_html(ctx),
|
||||||
|
aside_html=search_desktop_html(ctx),
|
||||||
|
content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_orders_rows(ctx: dict, orders: list, page: int,
|
||||||
|
total_pages: int, url_for_fn: Any,
|
||||||
|
qs_fn: Any) -> str:
|
||||||
|
"""Pagination: just the table rows."""
|
||||||
|
return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_orders_oob(ctx: dict, orders: list, page: int,
|
||||||
|
total_pages: int, search: str | None,
|
||||||
|
search_count: int, url_for_fn: Any,
|
||||||
|
qs_fn: Any) -> str:
|
||||||
|
"""OOB response for orders list."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
|
||||||
|
ctx["search"] = search
|
||||||
|
ctx["search_count"] = search_count
|
||||||
|
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||||
|
|
||||||
|
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||||
|
main = _orders_main_panel_html(orders, rows)
|
||||||
|
|
||||||
|
oobs = (
|
||||||
|
_auth_header_html(ctx, oob=True)
|
||||||
|
+ sexp(
|
||||||
|
'(div :id "auth-header-child" :hx-swap-oob "outerHTML"'
|
||||||
|
' :class "flex flex-col w-full items-center" (raw! o))',
|
||||||
|
o=_orders_header_html(ctx, list_url),
|
||||||
|
)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return oob_page(ctx, oobs_html=oobs,
|
||||||
|
filter_html=_orders_summary_html(ctx),
|
||||||
|
aside_html=search_desktop_html(ctx),
|
||||||
|
content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Single order detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_order_page(ctx: dict, order: Any,
|
||||||
|
calendar_entries: list | None,
|
||||||
|
url_for_fn: Any) -> str:
|
||||||
|
"""Full page: single order detail."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
pfx = route_prefix()
|
||||||
|
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||||
|
list_url = pfx + url_for_fn("orders.list_orders")
|
||||||
|
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||||
|
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||||
|
|
||||||
|
main = _order_main_html(order, calendar_entries)
|
||||||
|
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||||
|
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
order_row = sexp(
|
||||||
|
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp")',
|
||||||
|
lh=detail_url, ll=f"Order {order.id}",
|
||||||
|
)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
|
||||||
|
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)'
|
||||||
|
' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))',
|
||||||
|
a=_auth_header_html(ctx),
|
||||||
|
b=_orders_header_html(ctx, list_url),
|
||||||
|
c=order_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_order_oob(ctx: dict, order: Any,
|
||||||
|
calendar_entries: list | None,
|
||||||
|
url_for_fn: Any) -> str:
|
||||||
|
"""OOB response for single order detail."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
pfx = route_prefix()
|
||||||
|
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||||
|
list_url = pfx + url_for_fn("orders.list_orders")
|
||||||
|
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||||
|
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||||
|
|
||||||
|
main = _order_main_html(order, calendar_entries)
|
||||||
|
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||||
|
|
||||||
|
order_row_oob = sexp(
|
||||||
|
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp" :oob true)',
|
||||||
|
lh=detail_url, ll=f"Order {order.id}",
|
||||||
|
)
|
||||||
|
oobs = (
|
||||||
|
sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main)
|
||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
- ./blog/alembic.ini:/app/blog/alembic.ini:ro
|
- ./blog/alembic.ini:/app/blog/alembic.ini:ro
|
||||||
- ./blog/alembic:/app/blog/alembic:ro
|
- ./blog/alembic:/app/blog/alembic:ro
|
||||||
- ./blog/app.py:/app/app.py
|
- ./blog/app.py:/app/app.py
|
||||||
|
- ./blog/sexp_components.py:/app/sexp_components.py
|
||||||
- ./blog/bp:/app/bp
|
- ./blog/bp:/app/bp
|
||||||
- ./blog/services:/app/services
|
- ./blog/services:/app/services
|
||||||
- ./blog/templates:/app/templates
|
- ./blog/templates:/app/templates
|
||||||
@@ -82,6 +83,7 @@ services:
|
|||||||
- ./market/alembic.ini:/app/market/alembic.ini:ro
|
- ./market/alembic.ini:/app/market/alembic.ini:ro
|
||||||
- ./market/alembic:/app/market/alembic:ro
|
- ./market/alembic:/app/market/alembic:ro
|
||||||
- ./market/app.py:/app/app.py
|
- ./market/app.py:/app/app.py
|
||||||
|
- ./market/sexp_components.py:/app/sexp_components.py
|
||||||
- ./market/bp:/app/bp
|
- ./market/bp:/app/bp
|
||||||
- ./market/services:/app/services
|
- ./market/services:/app/services
|
||||||
- ./market/templates:/app/templates
|
- ./market/templates:/app/templates
|
||||||
@@ -118,6 +120,7 @@ services:
|
|||||||
- ./cart/alembic.ini:/app/cart/alembic.ini:ro
|
- ./cart/alembic.ini:/app/cart/alembic.ini:ro
|
||||||
- ./cart/alembic:/app/cart/alembic:ro
|
- ./cart/alembic:/app/cart/alembic:ro
|
||||||
- ./cart/app.py:/app/app.py
|
- ./cart/app.py:/app/app.py
|
||||||
|
- ./cart/sexp_components.py:/app/sexp_components.py
|
||||||
- ./cart/bp:/app/bp
|
- ./cart/bp:/app/bp
|
||||||
- ./cart/services:/app/services
|
- ./cart/services:/app/services
|
||||||
- ./cart/templates:/app/templates
|
- ./cart/templates:/app/templates
|
||||||
@@ -154,6 +157,7 @@ services:
|
|||||||
- ./events/alembic.ini:/app/events/alembic.ini:ro
|
- ./events/alembic.ini:/app/events/alembic.ini:ro
|
||||||
- ./events/alembic:/app/events/alembic:ro
|
- ./events/alembic:/app/events/alembic:ro
|
||||||
- ./events/app.py:/app/app.py
|
- ./events/app.py:/app/app.py
|
||||||
|
- ./events/sexp_components.py:/app/sexp_components.py
|
||||||
- ./events/bp:/app/bp
|
- ./events/bp:/app/bp
|
||||||
- ./events/services:/app/services
|
- ./events/services:/app/services
|
||||||
- ./events/templates:/app/templates
|
- ./events/templates:/app/templates
|
||||||
@@ -190,6 +194,7 @@ services:
|
|||||||
- ./federation/alembic.ini:/app/federation/alembic.ini:ro
|
- ./federation/alembic.ini:/app/federation/alembic.ini:ro
|
||||||
- ./federation/alembic:/app/federation/alembic:ro
|
- ./federation/alembic:/app/federation/alembic:ro
|
||||||
- ./federation/app.py:/app/app.py
|
- ./federation/app.py:/app/app.py
|
||||||
|
- ./federation/sexp_components.py:/app/sexp_components.py
|
||||||
- ./federation/bp:/app/bp
|
- ./federation/bp:/app/bp
|
||||||
- ./federation/services:/app/services
|
- ./federation/services:/app/services
|
||||||
- ./federation/templates:/app/templates
|
- ./federation/templates:/app/templates
|
||||||
@@ -226,6 +231,7 @@ services:
|
|||||||
- ./account/alembic.ini:/app/account/alembic.ini:ro
|
- ./account/alembic.ini:/app/account/alembic.ini:ro
|
||||||
- ./account/alembic:/app/account/alembic:ro
|
- ./account/alembic:/app/account/alembic:ro
|
||||||
- ./account/app.py:/app/app.py
|
- ./account/app.py:/app/app.py
|
||||||
|
- ./account/sexp_components.py:/app/sexp_components.py
|
||||||
- ./account/bp:/app/bp
|
- ./account/bp:/app/bp
|
||||||
- ./account/services:/app/services
|
- ./account/services:/app/services
|
||||||
- ./account/templates:/app/templates
|
- ./account/templates:/app/templates
|
||||||
@@ -324,6 +330,7 @@ services:
|
|||||||
- ./orders/alembic.ini:/app/orders/alembic.ini:ro
|
- ./orders/alembic.ini:/app/orders/alembic.ini:ro
|
||||||
- ./orders/alembic:/app/orders/alembic:ro
|
- ./orders/alembic:/app/orders/alembic:ro
|
||||||
- ./orders/app.py:/app/app.py
|
- ./orders/app.py:/app/app.py
|
||||||
|
- ./orders/sexp_components.py:/app/sexp_components.py
|
||||||
- ./orders/bp:/app/bp
|
- ./orders/bp:/app/bp
|
||||||
- ./orders/services:/app/services
|
- ./orders/services:/app/services
|
||||||
- ./orders/templates:/app/templates
|
- ./orders/templates:/app/templates
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, abort, request
|
from quart import g, abort, request
|
||||||
|
|||||||
@@ -65,19 +65,14 @@ def register() -> Blueprint:
|
|||||||
|
|
||||||
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
||||||
|
|
||||||
ctx = dict(
|
from shared.sexp.page import get_template_context
|
||||||
entries=entries,
|
from sexp_components import render_all_events_page, render_all_events_oob
|
||||||
has_more=has_more,
|
|
||||||
pending_tickets=pending_tickets,
|
|
||||||
page_info=page_info,
|
|
||||||
page=page,
|
|
||||||
view=view,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if is_htmx_request():
|
if is_htmx_request():
|
||||||
html = await render_template("_types/all_events/_main_panel.html", **ctx)
|
html = await render_all_events_oob(ctx, entries, has_more, pending_tickets, page_info, page, view)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/all_events/index.html", **ctx)
|
html = await render_all_events_page(ctx, entries, has_more, pending_tickets, page_info, page, view)
|
||||||
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@@ -88,15 +83,8 @@ def register() -> Blueprint:
|
|||||||
|
|
||||||
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
entries, has_more, pending_tickets, page_info = await _load_entries(page)
|
||||||
|
|
||||||
html = await render_template(
|
from sexp_components import render_all_events_cards
|
||||||
"_types/all_events/_cards.html",
|
html = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
|
||||||
entries=entries,
|
|
||||||
has_more=has_more,
|
|
||||||
pending_tickets=pending_tickets,
|
|
||||||
page_info=page_info,
|
|
||||||
page=page,
|
|
||||||
view=view,
|
|
||||||
)
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@bp.post("/all-tickets/adjust")
|
@bp.post("/all-tickets/adjust")
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ def register():
|
|||||||
async def admin(calendar_slug: str, **kwargs):
|
async def admin(calendar_slug: str, **kwargs):
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_calendar_admin_page, render_calendar_admin_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_calendar_admin_page(tctx)
|
||||||
html = await render_template("_types/calendar/admin/index.html")
|
|
||||||
else:
|
else:
|
||||||
# HTMX request: main panel + OOB elements
|
html = await render_calendar_admin_oob(tctx)
|
||||||
html = await render_template("_types/calendar/admin/_oob_elements.html")
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -142,47 +142,25 @@ def register():
|
|||||||
user_entries = visible.user_entries
|
user_entries = visible.user_entries
|
||||||
confirmed_entries = visible.confirmed_entries
|
confirmed_entries = visible.confirmed_entries
|
||||||
|
|
||||||
if not is_htmx_request():
|
from shared.sexp.page import get_template_context
|
||||||
# Normal browser request: full page with layout
|
from sexp_components import render_calendar_page, render_calendar_oob
|
||||||
html = await render_template(
|
|
||||||
"_types/calendar/index.html",
|
tctx = await get_template_context()
|
||||||
|
tctx.update(dict(
|
||||||
qsession=qsession,
|
qsession=qsession,
|
||||||
year=year,
|
year=year, month=month, month_name=month_name,
|
||||||
month=month,
|
weekday_names=weekday_names, weeks=weeks,
|
||||||
month_name=month_name,
|
prev_month=prev_month, prev_month_year=prev_month_year,
|
||||||
weekday_names=weekday_names,
|
next_month=next_month, next_month_year=next_month_year,
|
||||||
weeks=weeks,
|
prev_year=prev_year, next_year=next_year,
|
||||||
prev_month=prev_month,
|
user_entries=user_entries, confirmed_entries=confirmed_entries,
|
||||||
prev_month_year=prev_month_year,
|
month_entries=month_entries,
|
||||||
next_month=next_month,
|
))
|
||||||
next_month_year=next_month_year,
|
if not is_htmx_request():
|
||||||
prev_year=prev_year,
|
html = await render_calendar_page(tctx)
|
||||||
next_year=next_year,
|
|
||||||
user_entries=user_entries,
|
|
||||||
confirmed_entries=confirmed_entries,
|
|
||||||
month_entries=month_entries,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
|
html = await render_calendar_oob(tctx)
|
||||||
html = await render_template(
|
|
||||||
"_types/calendar/_oob_elements.html",
|
|
||||||
qsession=qsession,
|
|
||||||
year=year,
|
|
||||||
month=month,
|
|
||||||
month_name=month_name,
|
|
||||||
weekday_names=weekday_names,
|
|
||||||
weeks=weeks,
|
|
||||||
prev_month=prev_month,
|
|
||||||
prev_month_year=prev_month_year,
|
|
||||||
next_month=next_month,
|
|
||||||
next_month_year=next_month_year,
|
|
||||||
prev_year=prev_year,
|
|
||||||
next_year=next_year,
|
|
||||||
user_entries=user_entries,
|
|
||||||
confirmed_entries=confirmed_entries,
|
|
||||||
month_entries=month_entries,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@cache_page(tag="calendars")
|
@cache_page(tag="calendars")
|
||||||
async def home(**kwargs):
|
async def home(**kwargs):
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_calendars_page, render_calendars_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_calendars_page(ctx)
|
||||||
"_types/calendars/index.html",
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_calendars_oob(ctx)
|
||||||
"_types/calendars/_oob_elements.html",
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ def register():
|
|||||||
async def admin(year: int, month: int, day: int, **kwargs):
|
async def admin(year: int, month: int, day: int, **kwargs):
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_day_admin_page, render_day_admin_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_day_admin_page(tctx)
|
||||||
html = await render_template("_types/day/admin/index.html")
|
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/day/admin/_oob_elements.html")
|
html = await render_day_admin_oob(tctx)
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -120,16 +120,14 @@ def register():
|
|||||||
- all confirmed + provisional + ordered entries for that day (all users)
|
- all confirmed + provisional + ordered entries for that day (all users)
|
||||||
- pending only for current user/session
|
- pending only for current user/session
|
||||||
"""
|
"""
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_day_page, render_day_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_day_page(tctx)
|
||||||
html = await render_template(
|
|
||||||
"_types/day/index.html",
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
|
html = await render_day_oob(tctx)
|
||||||
html = await render_template(
|
|
||||||
"_types/day/_oob_elements.html",
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@bp.get("/w/<widget_domain>/")
|
@bp.get("/w/<widget_domain>/")
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ def register():
|
|||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
async def home(**kwargs):
|
async def home(**kwargs):
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_markets_page, render_markets_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/markets/index.html")
|
html = await render_markets_page(ctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/markets/_oob_elements.html")
|
html = await render_markets_oob(ctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@bp.post("/new/")
|
@bp.post("/new/")
|
||||||
|
|||||||
@@ -45,19 +45,14 @@ def register() -> Blueprint:
|
|||||||
|
|
||||||
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
|
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
|
||||||
|
|
||||||
ctx = dict(
|
from shared.sexp.page import get_template_context
|
||||||
entries=entries,
|
from sexp_components import render_page_summary_page, render_page_summary_oob
|
||||||
has_more=has_more,
|
|
||||||
pending_tickets=pending_tickets,
|
|
||||||
page_info={},
|
|
||||||
page=page,
|
|
||||||
view=view,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if is_htmx_request():
|
if is_htmx_request():
|
||||||
html = await render_template("_types/page_summary/_main_panel.html", **ctx)
|
html = await render_page_summary_oob(ctx, entries, has_more, pending_tickets, {}, page, view)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/page_summary/index.html", **ctx)
|
html = await render_page_summary_page(ctx, entries, has_more, pending_tickets, {}, page, view)
|
||||||
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@@ -69,15 +64,8 @@ def register() -> Blueprint:
|
|||||||
|
|
||||||
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
|
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
|
||||||
|
|
||||||
html = await render_template(
|
from sexp_components import render_page_summary_cards
|
||||||
"_types/page_summary/_cards.html",
|
html = await render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post)
|
||||||
entries=entries,
|
|
||||||
has_more=has_more,
|
|
||||||
pending_tickets=pending_tickets,
|
|
||||||
page_info={},
|
|
||||||
page=page,
|
|
||||||
view=view,
|
|
||||||
)
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@bp.post("/tickets/adjust")
|
@bp.post("/tickets/adjust")
|
||||||
|
|||||||
@@ -42,11 +42,17 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def home(**kwargs):
|
async def home(**kwargs):
|
||||||
ctx = await _load_payment_ctx()
|
pay_ctx = await _load_payment_ctx()
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_payments_page, render_payments_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
ctx.update(pay_ctx)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/payments/index.html", **ctx)
|
html = await render_payments_page(ctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/payments/_oob_elements.html", **ctx)
|
html = await render_payments_oob(ctx)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@bp.put("/")
|
@bp.put("/")
|
||||||
|
|||||||
@@ -70,18 +70,14 @@ def register() -> Blueprint:
|
|||||||
"reserved": reserved or 0,
|
"reserved": reserved or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_ticket_admin_page, render_ticket_admin_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_ticket_admin_page(ctx, tickets, stats)
|
||||||
"_types/ticket_admin/index.html",
|
|
||||||
tickets=tickets,
|
|
||||||
stats=stats,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_ticket_admin_oob(ctx, tickets, stats)
|
||||||
"_types/ticket_admin/_main_panel.html",
|
|
||||||
tickets=tickets,
|
|
||||||
stats=stats,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
|||||||
@@ -50,16 +50,14 @@ def register() -> Blueprint:
|
|||||||
session_id=ident["session_id"],
|
session_id=ident["session_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_tickets_page, render_tickets_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_tickets_page(ctx, tickets)
|
||||||
"_types/tickets/index.html",
|
|
||||||
tickets=tickets,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_tickets_oob(ctx, tickets)
|
||||||
"_types/tickets/_main_panel.html",
|
|
||||||
tickets=tickets,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@@ -83,16 +81,14 @@ def register() -> Blueprint:
|
|||||||
else:
|
else:
|
||||||
return await make_response("Ticket not found", 404)
|
return await make_response("Ticket not found", 404)
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_ticket_detail_page, render_ticket_detail_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template(
|
html = await render_ticket_detail_page(ctx, ticket)
|
||||||
"_types/tickets/detail.html",
|
|
||||||
ticket=ticket,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
html = await render_template(
|
html = await render_ticket_detail_oob(ctx, ticket)
|
||||||
"_types/tickets/_detail_panel.html",
|
|
||||||
ticket=ticket,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
|||||||
1872
events/sexp_components.py
Normal file
1872
events/sexp_components.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, request
|
from quart import g, request
|
||||||
@@ -93,8 +94,13 @@ def create_app() -> "Quart":
|
|||||||
# --- home page ---
|
# --- home page ---
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def home():
|
async def home():
|
||||||
from quart import render_template
|
from quart import make_response
|
||||||
return await render_template("_types/federation/index.html")
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_federation_home
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
html = await render_federation_home(ctx)
|
||||||
|
return await make_response(html)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ def register(url_prefix="/auth"):
|
|||||||
# If there's a pending redirect (e.g. OAuth authorize), follow it
|
# If there's a pending redirect (e.g. OAuth authorize), follow it
|
||||||
redirect_url = pop_login_redirect_target()
|
redirect_url = pop_login_redirect_target()
|
||||||
return redirect(redirect_url)
|
return redirect(redirect_url)
|
||||||
return await render_template("auth/login.html")
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_login_page
|
||||||
|
ctx = await get_template_context()
|
||||||
|
return await render_login_page(ctx)
|
||||||
|
|
||||||
@auth_bp.post("/start/")
|
@auth_bp.post("/start/")
|
||||||
async def start_login():
|
async def start_login():
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ def register(url_prefix="/identity"):
|
|||||||
if actor:
|
if actor:
|
||||||
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
|
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
|
||||||
|
|
||||||
return await render_template("federation/choose_username.html")
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_choose_username_page
|
||||||
|
ctx = await get_template_context()
|
||||||
|
ctx["actor"] = actor
|
||||||
|
return await render_choose_username_page(ctx)
|
||||||
|
|
||||||
@bp.post("/choose-username")
|
@bp.post("/choose-username")
|
||||||
async def choose_username():
|
async def choose_username():
|
||||||
|
|||||||
@@ -39,12 +39,10 @@ def register(url_prefix="/social"):
|
|||||||
return redirect(url_for("auth.login_form"))
|
return redirect(url_for("auth.login_form"))
|
||||||
actor = _require_actor()
|
actor = _require_actor()
|
||||||
items = await services.federation.get_home_timeline(g.s, actor.id)
|
items = await services.federation.get_home_timeline(g.s, actor.id)
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/timeline.html",
|
from sexp_components import render_timeline_page
|
||||||
items=items,
|
ctx = await get_template_context()
|
||||||
timeline_type="home",
|
return await render_timeline_page(ctx, items, "home", actor)
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/timeline")
|
@bp.get("/timeline")
|
||||||
async def home_timeline_page():
|
async def home_timeline_page():
|
||||||
@@ -59,23 +57,17 @@ def register(url_prefix="/social"):
|
|||||||
items = await services.federation.get_home_timeline(
|
items = await services.federation.get_home_timeline(
|
||||||
g.s, actor.id, before=before,
|
g.s, actor.id, before=before,
|
||||||
)
|
)
|
||||||
return await render_template(
|
from sexp_components import render_timeline_items
|
||||||
"federation/_timeline_items.html",
|
return await render_timeline_items(items, "home", actor)
|
||||||
items=items,
|
|
||||||
timeline_type="home",
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/public")
|
@bp.get("/public")
|
||||||
async def public_timeline():
|
async def public_timeline():
|
||||||
items = await services.federation.get_public_timeline(g.s)
|
items = await services.federation.get_public_timeline(g.s)
|
||||||
actor = getattr(g, "_social_actor", None)
|
actor = getattr(g, "_social_actor", None)
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/timeline.html",
|
from sexp_components import render_timeline_page
|
||||||
items=items,
|
ctx = await get_template_context()
|
||||||
timeline_type="public",
|
return await render_timeline_page(ctx, items, "public", actor)
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/public/timeline")
|
@bp.get("/public/timeline")
|
||||||
async def public_timeline_page():
|
async def public_timeline_page():
|
||||||
@@ -88,12 +80,8 @@ def register(url_prefix="/social"):
|
|||||||
pass
|
pass
|
||||||
items = await services.federation.get_public_timeline(g.s, before=before)
|
items = await services.federation.get_public_timeline(g.s, before=before)
|
||||||
actor = getattr(g, "_social_actor", None)
|
actor = getattr(g, "_social_actor", None)
|
||||||
return await render_template(
|
from sexp_components import render_timeline_items
|
||||||
"federation/_timeline_items.html",
|
return await render_timeline_items(items, "public", actor)
|
||||||
items=items,
|
|
||||||
timeline_type="public",
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Compose --------------------------------------------------------------
|
# -- Compose --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -101,11 +89,10 @@ def register(url_prefix="/social"):
|
|||||||
async def compose_form():
|
async def compose_form():
|
||||||
actor = _require_actor()
|
actor = _require_actor()
|
||||||
reply_to = request.args.get("reply_to")
|
reply_to = request.args.get("reply_to")
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/compose.html",
|
from sexp_components import render_compose_page
|
||||||
actor=actor,
|
ctx = await get_template_context()
|
||||||
reply_to=reply_to,
|
return await render_compose_page(ctx, actor, reply_to)
|
||||||
)
|
|
||||||
|
|
||||||
@bp.post("/compose")
|
@bp.post("/compose")
|
||||||
async def compose_submit():
|
async def compose_submit():
|
||||||
@@ -148,15 +135,10 @@ def register(url_prefix="/social"):
|
|||||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||||
)
|
)
|
||||||
followed_urls = {a.actor_url for a in following}
|
followed_urls = {a.actor_url for a in following}
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/search.html",
|
from sexp_components import render_search_page
|
||||||
query=query,
|
ctx = await get_template_context()
|
||||||
actors=actors,
|
return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor)
|
||||||
total=total,
|
|
||||||
page=1,
|
|
||||||
followed_urls=followed_urls,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/search/page")
|
@bp.get("/search/page")
|
||||||
async def search_page():
|
async def search_page():
|
||||||
@@ -175,15 +157,8 @@ def register(url_prefix="/social"):
|
|||||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||||
)
|
)
|
||||||
followed_urls = {a.actor_url for a in following}
|
followed_urls = {a.actor_url for a in following}
|
||||||
return await render_template(
|
from sexp_components import render_search_results
|
||||||
"federation/_search_results.html",
|
return await render_search_results(actors, query, page, followed_urls, actor)
|
||||||
actors=actors,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
query=query,
|
|
||||||
followed_urls=followed_urls,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.post("/follow")
|
@bp.post("/follow")
|
||||||
async def follow():
|
async def follow():
|
||||||
@@ -340,13 +315,10 @@ def register(url_prefix="/social"):
|
|||||||
actors, total = await services.federation.get_following(
|
actors, total = await services.federation.get_following(
|
||||||
g.s, actor.preferred_username,
|
g.s, actor.preferred_username,
|
||||||
)
|
)
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/following.html",
|
from sexp_components import render_following_page
|
||||||
actors=actors,
|
ctx = await get_template_context()
|
||||||
total=total,
|
return await render_following_page(ctx, actors, total, actor)
|
||||||
page=1,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/following/page")
|
@bp.get("/following/page")
|
||||||
async def following_list_page():
|
async def following_list_page():
|
||||||
@@ -355,15 +327,8 @@ def register(url_prefix="/social"):
|
|||||||
actors, total = await services.federation.get_following(
|
actors, total = await services.federation.get_following(
|
||||||
g.s, actor.preferred_username, page=page,
|
g.s, actor.preferred_username, page=page,
|
||||||
)
|
)
|
||||||
return await render_template(
|
from sexp_components import render_following_items
|
||||||
"federation/_actor_list_items.html",
|
return await render_following_items(actors, page, actor)
|
||||||
actors=actors,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
list_type="following",
|
|
||||||
followed_urls=set(),
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/followers")
|
@bp.get("/followers")
|
||||||
async def followers_list():
|
async def followers_list():
|
||||||
@@ -376,14 +341,10 @@ def register(url_prefix="/social"):
|
|||||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||||
)
|
)
|
||||||
followed_urls = {a.actor_url for a in following}
|
followed_urls = {a.actor_url for a in following}
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/followers.html",
|
from sexp_components import render_followers_page
|
||||||
actors=actors,
|
ctx = await get_template_context()
|
||||||
total=total,
|
return await render_followers_page(ctx, actors, total, followed_urls, actor)
|
||||||
page=1,
|
|
||||||
followed_urls=followed_urls,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/followers/page")
|
@bp.get("/followers/page")
|
||||||
async def followers_list_page():
|
async def followers_list_page():
|
||||||
@@ -396,15 +357,8 @@ def register(url_prefix="/social"):
|
|||||||
g.s, actor.preferred_username, page=1, per_page=1000,
|
g.s, actor.preferred_username, page=1, per_page=1000,
|
||||||
)
|
)
|
||||||
followed_urls = {a.actor_url for a in following}
|
followed_urls = {a.actor_url for a in following}
|
||||||
return await render_template(
|
from sexp_components import render_followers_items
|
||||||
"federation/_actor_list_items.html",
|
return await render_followers_items(actors, page, followed_urls, actor)
|
||||||
actors=actors,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
list_type="followers",
|
|
||||||
followed_urls=followed_urls,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/actor/<int:id>")
|
@bp.get("/actor/<int:id>")
|
||||||
async def actor_timeline(id: int):
|
async def actor_timeline(id: int):
|
||||||
@@ -435,13 +389,10 @@ def register(url_prefix="/social"):
|
|||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
is_following = existing is not None
|
is_following = existing is not None
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/actor_timeline.html",
|
from sexp_components import render_actor_timeline_page
|
||||||
remote_actor=remote_dto,
|
ctx = await get_template_context()
|
||||||
items=items,
|
return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor)
|
||||||
is_following=is_following,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/actor/<int:id>/timeline")
|
@bp.get("/actor/<int:id>/timeline")
|
||||||
async def actor_timeline_page(id: int):
|
async def actor_timeline_page(id: int):
|
||||||
@@ -456,13 +407,8 @@ def register(url_prefix="/social"):
|
|||||||
items = await services.federation.get_actor_timeline(
|
items = await services.federation.get_actor_timeline(
|
||||||
g.s, id, before=before,
|
g.s, id, before=before,
|
||||||
)
|
)
|
||||||
return await render_template(
|
from sexp_components import render_actor_timeline_items
|
||||||
"federation/_timeline_items.html",
|
return await render_actor_timeline_items(items, id, actor)
|
||||||
items=items,
|
|
||||||
timeline_type="actor",
|
|
||||||
actor_id=id,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Notifications --------------------------------------------------------
|
# -- Notifications --------------------------------------------------------
|
||||||
|
|
||||||
@@ -471,11 +417,10 @@ def register(url_prefix="/social"):
|
|||||||
actor = _require_actor()
|
actor = _require_actor()
|
||||||
items = await services.federation.get_notifications(g.s, actor.id)
|
items = await services.federation.get_notifications(g.s, actor.id)
|
||||||
await services.federation.mark_notifications_read(g.s, actor.id)
|
await services.federation.mark_notifications_read(g.s, actor.id)
|
||||||
return await render_template(
|
from shared.sexp.page import get_template_context
|
||||||
"federation/notifications.html",
|
from sexp_components import render_notifications_page
|
||||||
notifications=items,
|
ctx = await get_template_context()
|
||||||
actor=actor,
|
return await render_notifications_page(ctx, items, actor)
|
||||||
)
|
|
||||||
|
|
||||||
@bp.get("/notifications/count")
|
@bp.get("/notifications/count")
|
||||||
async def notification_count():
|
async def notification_count():
|
||||||
|
|||||||
710
federation/sexp_components.py
Normal file
710
federation/sexp_components.py
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
"""
|
||||||
|
Federation service s-expression page components.
|
||||||
|
|
||||||
|
Renders social timeline, compose, search, following/followers, notifications,
|
||||||
|
actor profiles, login, and username selection pages.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from markupsafe import escape
|
||||||
|
|
||||||
|
from shared.sexp.jinja_bridge import sexp
|
||||||
|
from shared.sexp.helpers import root_header_html, full_page
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Social header nav
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _social_nav_html(actor: Any) -> str:
|
||||||
|
"""Build the social header nav bar content."""
|
||||||
|
from quart import url_for, request
|
||||||
|
|
||||||
|
if not actor:
|
||||||
|
choose_url = url_for("identity.choose_username_form")
|
||||||
|
return (
|
||||||
|
'<nav class="flex gap-3 text-sm items-center">'
|
||||||
|
f'<a href="{choose_url}" class="px-2 py-1 rounded hover:bg-stone-200 font-bold">Choose username</a>'
|
||||||
|
'</nav>'
|
||||||
|
)
|
||||||
|
|
||||||
|
links = [
|
||||||
|
("social.home_timeline", "Timeline"),
|
||||||
|
("social.public_timeline", "Public"),
|
||||||
|
("social.compose_form", "Compose"),
|
||||||
|
("social.following_list", "Following"),
|
||||||
|
("social.followers_list", "Followers"),
|
||||||
|
("social.search", "Search"),
|
||||||
|
]
|
||||||
|
|
||||||
|
parts = ['<nav class="flex gap-3 text-sm items-center flex-wrap">']
|
||||||
|
for endpoint, label in links:
|
||||||
|
href = url_for(endpoint)
|
||||||
|
bold = " font-bold" if request.path == href else ""
|
||||||
|
parts.append(f'<a href="{href}" class="px-2 py-1 rounded hover:bg-stone-200{bold}">{label}</a>')
|
||||||
|
|
||||||
|
# Notifications with live badge
|
||||||
|
notif_url = url_for("social.notifications")
|
||||||
|
notif_count_url = url_for("social.notification_count")
|
||||||
|
notif_bold = " font-bold" if request.path == notif_url else ""
|
||||||
|
parts.append(
|
||||||
|
f'<a href="{notif_url}" class="px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}">Notifications'
|
||||||
|
f'<span hx-get="{notif_count_url}" hx-trigger="load, every 30s" hx-swap="innerHTML"'
|
||||||
|
f' class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span></a>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Profile link
|
||||||
|
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
|
||||||
|
parts.append(f'<a href="{profile_url}" class="px-2 py-1 rounded hover:bg-stone-200">@{actor.preferred_username}</a>')
|
||||||
|
parts.append('</nav>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _social_header_html(actor: Any) -> str:
|
||||||
|
"""Build the social section header row."""
|
||||||
|
nav_html = _social_nav_html(actor)
|
||||||
|
return sexp(
|
||||||
|
'(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"'
|
||||||
|
' (div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nh)))',
|
||||||
|
nh=nav_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _social_page(ctx: dict, actor: Any, *, content_html: str,
|
||||||
|
title: str = "Rose Ash", meta_html: str = "") -> str:
|
||||||
|
"""Render a social page with header and content."""
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! sh))',
|
||||||
|
sh=_social_header_html(actor),
|
||||||
|
)
|
||||||
|
return full_page(ctx, header_rows_html=hdr, content_html=content_html,
|
||||||
|
meta_html=meta_html or f'<title>{escape(title)}</title>')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post card
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _interaction_buttons_html(item: Any, actor: Any) -> str:
|
||||||
|
"""Render like/boost/reply buttons for a post."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
oid = getattr(item, "object_id", "") or ""
|
||||||
|
ainbox = getattr(item, "author_inbox", "") or ""
|
||||||
|
lcount = getattr(item, "like_count", 0) or 0
|
||||||
|
bcount = getattr(item, "boost_count", 0) or 0
|
||||||
|
liked = getattr(item, "liked_by_me", False)
|
||||||
|
boosted = getattr(item, "boosted_by_me", False)
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
|
safe_id = oid.replace("/", "_").replace(":", "_")
|
||||||
|
target = f"#interactions-{safe_id}"
|
||||||
|
|
||||||
|
if liked:
|
||||||
|
like_action = url_for("social.unlike")
|
||||||
|
like_cls = "text-red-500 hover:text-red-600"
|
||||||
|
like_icon = "♥"
|
||||||
|
else:
|
||||||
|
like_action = url_for("social.like")
|
||||||
|
like_cls = "hover:text-red-500"
|
||||||
|
like_icon = "♡"
|
||||||
|
|
||||||
|
if boosted:
|
||||||
|
boost_action = url_for("social.unboost")
|
||||||
|
boost_cls = "text-green-600 hover:text-green-700"
|
||||||
|
else:
|
||||||
|
boost_action = url_for("social.boost")
|
||||||
|
boost_cls = "hover:text-green-600"
|
||||||
|
|
||||||
|
reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
|
||||||
|
reply_html = f'<a href="{reply_url}" class="hover:text-stone-700">Reply</a>' if reply_url else ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">'
|
||||||
|
f'<form hx-post="{like_action}" hx-target="{target}" hx-swap="innerHTML">'
|
||||||
|
f'<input type="hidden" name="object_id" value="{oid}">'
|
||||||
|
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<button type="submit" class="flex items-center gap-1 {like_cls}"><span>{like_icon}</span> {lcount}</button></form>'
|
||||||
|
f'<form hx-post="{boost_action}" hx-target="{target}" hx-swap="innerHTML">'
|
||||||
|
f'<input type="hidden" name="object_id" value="{oid}">'
|
||||||
|
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<button type="submit" class="flex items-center gap-1 {boost_cls}"><span>↻</span> {bcount}</button></form>'
|
||||||
|
f'{reply_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_card_html(item: Any, actor: Any) -> str:
|
||||||
|
"""Render a single timeline post card."""
|
||||||
|
boosted_by = getattr(item, "boosted_by", None)
|
||||||
|
actor_icon = getattr(item, "actor_icon", None)
|
||||||
|
actor_name = getattr(item, "actor_name", "?")
|
||||||
|
actor_username = getattr(item, "actor_username", "")
|
||||||
|
actor_domain = getattr(item, "actor_domain", "")
|
||||||
|
content = getattr(item, "content", "")
|
||||||
|
summary = getattr(item, "summary", None)
|
||||||
|
published = getattr(item, "published", None)
|
||||||
|
url = getattr(item, "url", None)
|
||||||
|
post_type = getattr(item, "post_type", "")
|
||||||
|
|
||||||
|
boost_html = f'<div class="text-sm text-stone-500 mb-2">Boosted by {escape(boosted_by)}</div>' if boosted_by else ""
|
||||||
|
|
||||||
|
if actor_icon:
|
||||||
|
avatar = f'<img src="{actor_icon}" alt="" class="w-10 h-10 rounded-full">'
|
||||||
|
else:
|
||||||
|
initial = actor_name[0].upper() if actor_name else "?"
|
||||||
|
avatar = f'<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">{initial}</div>'
|
||||||
|
|
||||||
|
domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
|
||||||
|
time_html = published.strftime("%b %d, %H:%M") if published else ""
|
||||||
|
|
||||||
|
if summary:
|
||||||
|
content_html = (
|
||||||
|
f'<details class="mt-2"><summary class="text-stone-500 cursor-pointer">CW: {escape(summary)}</summary>'
|
||||||
|
f'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div></details>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content_html = f'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div>'
|
||||||
|
|
||||||
|
original_html = ""
|
||||||
|
if url and post_type == "remote":
|
||||||
|
original_html = f'<a href="{url}" target="_blank" rel="noopener" class="text-sm text-stone-400 hover:underline mt-1 inline-block">original</a>'
|
||||||
|
|
||||||
|
interactions_html = ""
|
||||||
|
if actor:
|
||||||
|
oid = getattr(item, "object_id", "") or ""
|
||||||
|
safe_id = oid.replace("/", "_").replace(":", "_")
|
||||||
|
interactions_html = f'<div id="interactions-{safe_id}">{_interaction_buttons_html(item, actor)}</div>'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">'
|
||||||
|
f'{boost_html}'
|
||||||
|
f'<div class="flex items-start gap-3">{avatar}'
|
||||||
|
f'<div class="flex-1 min-w-0">'
|
||||||
|
f'<div class="flex items-baseline gap-2">'
|
||||||
|
f'<span class="font-semibold text-stone-900">{escape(actor_name)}</span>'
|
||||||
|
f'<span class="text-sm text-stone-500">@{escape(actor_username)}{domain_html}</span>'
|
||||||
|
f'<span class="text-sm text-stone-400 ml-auto">{time_html}</span></div>'
|
||||||
|
f'{content_html}{original_html}{interactions_html}</div></div></article>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timeline items (pagination fragment)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _timeline_items_html(items: list, timeline_type: str, actor: Any,
|
||||||
|
actor_id: int | None = None) -> str:
|
||||||
|
"""Render timeline items with infinite scroll sentinel."""
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
parts = [_post_card_html(item, actor) for item in items]
|
||||||
|
|
||||||
|
if items:
|
||||||
|
last = items[-1]
|
||||||
|
before = last.published.isoformat() if last.published else ""
|
||||||
|
if timeline_type == "actor" and actor_id is not None:
|
||||||
|
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
|
||||||
|
else:
|
||||||
|
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
|
||||||
|
parts.append(f'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Search results (pagination fragment)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _actor_card_html(a: Any, actor: Any, followed_urls: set,
|
||||||
|
*, list_type: str = "search") -> str:
|
||||||
|
"""Render a single actor card with follow/unfollow button."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
|
||||||
|
username = getattr(a, "preferred_username", "")
|
||||||
|
domain = getattr(a, "domain", "")
|
||||||
|
icon_url = getattr(a, "icon_url", None)
|
||||||
|
actor_url = getattr(a, "actor_url", "")
|
||||||
|
summary = getattr(a, "summary", None)
|
||||||
|
aid = getattr(a, "id", None)
|
||||||
|
|
||||||
|
safe_id = actor_url.replace("/", "_").replace(":", "_")
|
||||||
|
|
||||||
|
if icon_url:
|
||||||
|
avatar = f'<img src="{icon_url}" alt="" class="w-12 h-12 rounded-full">'
|
||||||
|
else:
|
||||||
|
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
|
||||||
|
avatar = f'<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">{initial}</div>'
|
||||||
|
|
||||||
|
# Name link
|
||||||
|
if list_type == "following" and aid:
|
||||||
|
name_html = f'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
|
||||||
|
elif list_type == "search" and aid:
|
||||||
|
name_html = f'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
|
||||||
|
else:
|
||||||
|
name_html = f'<a href="https://{domain}/@{username}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
|
||||||
|
|
||||||
|
summary_html = f'<div class="text-sm text-stone-600 mt-1 truncate">{summary}</div>' if summary else ""
|
||||||
|
|
||||||
|
# Follow/unfollow button
|
||||||
|
button_html = ""
|
||||||
|
if actor:
|
||||||
|
is_followed = actor_url in (followed_urls or set())
|
||||||
|
if list_type == "following" or is_followed:
|
||||||
|
button_html = (
|
||||||
|
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}"'
|
||||||
|
f' hx-post="{url_for("social.unfollow")}" hx-target="closest article" hx-swap="outerHTML">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<input type="hidden" name="actor_url" value="{actor_url}">'
|
||||||
|
f'<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">Unfollow</button></form></div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
label = "Follow Back" if list_type == "followers" else "Follow"
|
||||||
|
button_html = (
|
||||||
|
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}"'
|
||||||
|
f' hx-post="{url_for("social.follow")}" hx-target="closest article" hx-swap="outerHTML">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<input type="hidden" name="actor_url" value="{actor_url}">'
|
||||||
|
f'<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">{label}</button></form></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" id="actor-{safe_id}">'
|
||||||
|
f'{avatar}<div class="flex-1 min-w-0">{name_html}'
|
||||||
|
f'<div class="text-sm text-stone-500">@{escape(username)}@{escape(domain)}</div>'
|
||||||
|
f'{summary_html}</div>{button_html}</article>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_results_html(actors: list, query: str, page: int,
|
||||||
|
followed_urls: set, actor: Any) -> str:
|
||||||
|
"""Render search results with pagination sentinel."""
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
parts = [_actor_card_html(a, actor, followed_urls, list_type="search") for a in actors]
|
||||||
|
if len(actors) >= 20:
|
||||||
|
next_url = url_for("social.search_page", q=query, page=page + 1)
|
||||||
|
parts.append(f'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_list_items_html(actors: list, page: int, list_type: str,
|
||||||
|
followed_urls: set, actor: Any) -> str:
|
||||||
|
"""Render actor list items (following/followers) with pagination sentinel."""
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
parts = [_actor_card_html(a, actor, followed_urls, list_type=list_type) for a in actors]
|
||||||
|
if len(actors) >= 20:
|
||||||
|
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
|
||||||
|
parts.append(f'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Notification card
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _notification_html(notif: Any) -> str:
|
||||||
|
"""Render a single notification."""
|
||||||
|
from_name = getattr(notif, "from_actor_name", "?")
|
||||||
|
from_username = getattr(notif, "from_actor_username", "")
|
||||||
|
from_domain = getattr(notif, "from_actor_domain", "")
|
||||||
|
from_icon = getattr(notif, "from_actor_icon", None)
|
||||||
|
ntype = getattr(notif, "notification_type", "")
|
||||||
|
preview = getattr(notif, "target_content_preview", None)
|
||||||
|
created = getattr(notif, "created_at", None)
|
||||||
|
read = getattr(notif, "read", True)
|
||||||
|
app_domain = getattr(notif, "app_domain", "")
|
||||||
|
|
||||||
|
border = " border-l-4 border-l-stone-400" if not read else ""
|
||||||
|
|
||||||
|
if from_icon:
|
||||||
|
avatar = f'<img src="{from_icon}" alt="" class="w-8 h-8 rounded-full">'
|
||||||
|
else:
|
||||||
|
initial = from_name[0].upper() if from_name else "?"
|
||||||
|
avatar = f'<div class="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs">{initial}</div>'
|
||||||
|
|
||||||
|
domain_html = f"@{escape(from_domain)}" if from_domain else ""
|
||||||
|
|
||||||
|
type_map = {
|
||||||
|
"follow": "followed you",
|
||||||
|
"like": "liked your post",
|
||||||
|
"boost": "boosted your post",
|
||||||
|
"mention": "mentioned you",
|
||||||
|
"reply": "replied to your post",
|
||||||
|
}
|
||||||
|
action = type_map.get(ntype, "")
|
||||||
|
if ntype == "follow" and app_domain and app_domain != "federation":
|
||||||
|
action += f" on {escape(app_domain)}"
|
||||||
|
|
||||||
|
preview_html = f'<div class="text-sm text-stone-500 mt-1 truncate">{escape(preview)}</div>' if preview else ""
|
||||||
|
time_html = created.strftime("%b %d, %H:%M") if created else ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}">'
|
||||||
|
f'<div class="flex items-start gap-3">{avatar}<div class="flex-1">'
|
||||||
|
f'<div class="text-sm"><span class="font-semibold">{escape(from_name)}</span>'
|
||||||
|
f' <span class="text-stone-500">@{escape(from_username)}{domain_html}</span>'
|
||||||
|
f' <span class="text-stone-600">{action}</span></div>'
|
||||||
|
f'{preview_html}<div class="text-xs text-stone-400 mt-1">{time_html}</div></div></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Home page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_federation_home(ctx: dict) -> str:
|
||||||
|
"""Full page: federation home (minimal)."""
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
return full_page(ctx, header_rows_html=hdr)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Login
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_login_page(ctx: dict) -> str:
|
||||||
|
"""Full page: federation login form."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
error = ctx.get("error", "")
|
||||||
|
email = ctx.get("email", "")
|
||||||
|
action = url_for("auth.start_login")
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
|
||||||
|
error_html = f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' if error else ""
|
||||||
|
content = (
|
||||||
|
f'<div class="py-8 max-w-md mx-auto"><h1 class="text-2xl font-bold mb-6">Sign in</h1>{error_html}'
|
||||||
|
f'<form method="post" action="{action}" class="space-y-4">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
|
||||||
|
f'<input type="email" name="email" id="email" value="{escape(email)}" required autofocus'
|
||||||
|
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
|
||||||
|
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
|
||||||
|
f'Send magic link</button></form></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
return full_page(ctx, header_rows_html=hdr, content_html=content,
|
||||||
|
meta_html="<title>Login \u2014 Rose Ash</title>")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Timeline
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
|
||||||
|
actor: Any) -> str:
|
||||||
|
"""Full page: timeline (home or public)."""
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
label = "Home" if timeline_type == "home" else "Public"
|
||||||
|
compose_html = ""
|
||||||
|
if actor:
|
||||||
|
compose_url = url_for("social.compose_form")
|
||||||
|
compose_html = f'<a href="{compose_url}" class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">Compose</a>'
|
||||||
|
|
||||||
|
timeline_html = _timeline_items_html(items, timeline_type, actor)
|
||||||
|
|
||||||
|
content = (
|
||||||
|
f'<div class="flex items-center justify-between mb-6">'
|
||||||
|
f'<h1 class="text-2xl font-bold">{label} Timeline</h1>{compose_html}</div>'
|
||||||
|
f'<div id="timeline">{timeline_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title=f"{label} Timeline \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
async def render_timeline_items(items: list, timeline_type: str,
|
||||||
|
actor: Any, actor_id: int | None = None) -> str:
|
||||||
|
"""Pagination fragment: timeline items."""
|
||||||
|
return _timeline_items_html(items, timeline_type, actor, actor_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Compose
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> str:
|
||||||
|
"""Full page: compose form."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
action = url_for("social.compose_submit")
|
||||||
|
|
||||||
|
reply_html = ""
|
||||||
|
if reply_to:
|
||||||
|
reply_html = (
|
||||||
|
f'<input type="hidden" name="in_reply_to" value="{escape(reply_to)}">'
|
||||||
|
f'<div class="text-sm text-stone-500">Replying to <span class="font-mono">{escape(reply_to)}</span></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
content = (
|
||||||
|
f'<h1 class="text-2xl font-bold mb-6">Compose</h1>'
|
||||||
|
f'<form method="post" action="{action}" class="space-y-4">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">{reply_html}'
|
||||||
|
f'<textarea name="content" rows="6" maxlength="5000" required'
|
||||||
|
f' class="w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"'
|
||||||
|
f' placeholder="What\'s on your mind?"></textarea>'
|
||||||
|
f'<div class="flex items-center justify-between">'
|
||||||
|
f'<select name="visibility" class="border border-stone-300 rounded px-3 py-1.5 text-sm">'
|
||||||
|
f'<option value="public">Public</option><option value="unlisted">Unlisted</option>'
|
||||||
|
f'<option value="followers">Followers only</option></select>'
|
||||||
|
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Publish</button></div></form>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title="Compose \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_search_page(ctx: dict, query: str, actors: list, total: int,
|
||||||
|
page: int, followed_urls: set, actor: Any) -> str:
|
||||||
|
"""Full page: search."""
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
search_url = url_for("social.search")
|
||||||
|
search_page_url = url_for("social.search_page")
|
||||||
|
|
||||||
|
results_html = _search_results_html(actors, query, page, followed_urls, actor)
|
||||||
|
|
||||||
|
info_html = ""
|
||||||
|
if query and total:
|
||||||
|
s = "s" if total != 1 else ""
|
||||||
|
info_html = f'<p class="text-sm text-stone-500 mb-4">{total} result{s} for <strong>{escape(query)}</strong></p>'
|
||||||
|
elif query:
|
||||||
|
info_html = f'<p class="text-stone-500 mb-4">No results found for <strong>{escape(query)}</strong></p>'
|
||||||
|
|
||||||
|
content = (
|
||||||
|
f'<h1 class="text-2xl font-bold mb-6">Search</h1>'
|
||||||
|
f'<form method="get" action="{search_url}" class="mb-6"'
|
||||||
|
f' hx-get="{search_page_url}" hx-target="#search-results" hx-push-url="{search_url}">'
|
||||||
|
f'<div class="flex gap-2"><input type="text" name="q" value="{escape(query)}"'
|
||||||
|
f' class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
|
||||||
|
f' placeholder="Search users or @user@instance.tld">'
|
||||||
|
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Search</button></div></form>'
|
||||||
|
f'{info_html}<div id="search-results">{results_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title="Search \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
async def render_search_results(actors: list, query: str, page: int,
|
||||||
|
followed_urls: set, actor: Any) -> str:
|
||||||
|
"""Pagination fragment: search results."""
|
||||||
|
return _search_results_html(actors, query, page, followed_urls, actor)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Following / Followers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_following_page(ctx: dict, actors: list, total: int,
|
||||||
|
actor: Any) -> str:
|
||||||
|
"""Full page: following list."""
|
||||||
|
items_html = _actor_list_items_html(actors, 1, "following", set(), actor)
|
||||||
|
content = (
|
||||||
|
f'<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({total})</span></h1>'
|
||||||
|
f'<div id="actor-list">{items_html}</div>'
|
||||||
|
)
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title="Following \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
async def render_following_items(actors: list, page: int, actor: Any) -> str:
|
||||||
|
"""Pagination fragment: following items."""
|
||||||
|
return _actor_list_items_html(actors, page, "following", set(), actor)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_followers_page(ctx: dict, actors: list, total: int,
|
||||||
|
followed_urls: set, actor: Any) -> str:
|
||||||
|
"""Full page: followers list."""
|
||||||
|
items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor)
|
||||||
|
content = (
|
||||||
|
f'<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({total})</span></h1>'
|
||||||
|
f'<div id="actor-list">{items_html}</div>'
|
||||||
|
)
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title="Followers \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
async def render_followers_items(actors: list, page: int,
|
||||||
|
followed_urls: set, actor: Any) -> str:
|
||||||
|
"""Pagination fragment: followers items."""
|
||||||
|
return _actor_list_items_html(actors, page, "followers", followed_urls, actor)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Actor timeline
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
|
||||||
|
is_following: bool, actor: Any) -> str:
|
||||||
|
"""Full page: remote actor timeline."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
display_name = remote_actor.display_name or remote_actor.preferred_username
|
||||||
|
icon_url = getattr(remote_actor, "icon_url", None)
|
||||||
|
summary = getattr(remote_actor, "summary", None)
|
||||||
|
actor_url = getattr(remote_actor, "actor_url", "")
|
||||||
|
|
||||||
|
if icon_url:
|
||||||
|
avatar = f'<img src="{icon_url}" alt="" class="w-16 h-16 rounded-full">'
|
||||||
|
else:
|
||||||
|
initial = display_name[0].upper() if display_name else "?"
|
||||||
|
avatar = f'<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">{initial}</div>'
|
||||||
|
|
||||||
|
summary_html = f'<div class="text-sm text-stone-600 mt-2">{summary}</div>' if summary else ""
|
||||||
|
|
||||||
|
follow_html = ""
|
||||||
|
if actor:
|
||||||
|
if is_following:
|
||||||
|
follow_html = (
|
||||||
|
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<input type="hidden" name="actor_url" value="{actor_url}">'
|
||||||
|
f'<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">Unfollow</button></form></div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
follow_html = (
|
||||||
|
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<input type="hidden" name="actor_url" value="{actor_url}">'
|
||||||
|
f'<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">Follow</button></form></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
|
||||||
|
|
||||||
|
content = (
|
||||||
|
f'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">'
|
||||||
|
f'<div class="flex items-center gap-4">{avatar}'
|
||||||
|
f'<div class="flex-1"><h1 class="text-xl font-bold">{escape(display_name)}</h1>'
|
||||||
|
f'<div class="text-stone-500">@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}</div>'
|
||||||
|
f'{summary_html}</div>{follow_html}</div></div>'
|
||||||
|
f'<div id="timeline">{timeline_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title=f"{display_name} \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
async def render_actor_timeline_items(items: list, actor_id: int,
|
||||||
|
actor: Any) -> str:
|
||||||
|
"""Pagination fragment: actor timeline items."""
|
||||||
|
return _timeline_items_html(items, "actor", actor, actor_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Notifications
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_notifications_page(ctx: dict, notifications: list,
|
||||||
|
actor: Any) -> str:
|
||||||
|
"""Full page: notifications."""
|
||||||
|
if not notifications:
|
||||||
|
notif_html = '<p class="text-stone-500">No notifications yet.</p>'
|
||||||
|
else:
|
||||||
|
notif_html = '<div class="space-y-2">' + "".join(_notification_html(n) for n in notifications) + '</div>'
|
||||||
|
|
||||||
|
content = f'<h1 class="text-2xl font-bold mb-6">Notifications</h1>{notif_html}'
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title="Notifications \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Choose username
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_choose_username_page(ctx: dict) -> str:
|
||||||
|
"""Full page: choose username form."""
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
from quart import url_for
|
||||||
|
from shared.config import config
|
||||||
|
|
||||||
|
csrf = generate_csrf_token()
|
||||||
|
error = ctx.get("error", "")
|
||||||
|
username = ctx.get("username", "")
|
||||||
|
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||||
|
check_url = url_for("identity.check_username")
|
||||||
|
actor = ctx.get("actor")
|
||||||
|
|
||||||
|
error_html = f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' if error else ""
|
||||||
|
|
||||||
|
content = (
|
||||||
|
f'<div class="py-8 max-w-md mx-auto">'
|
||||||
|
f'<h1 class="text-2xl font-bold mb-2">Choose your username</h1>'
|
||||||
|
f'<p class="text-stone-600 mb-6">This will be your identity on the fediverse: '
|
||||||
|
f'<strong>@username@{escape(ap_domain)}</strong></p>'
|
||||||
|
f'{error_html}'
|
||||||
|
f'<form method="post" class="space-y-4">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{csrf}">'
|
||||||
|
f'<div><label for="username" class="block text-sm font-medium mb-1">Username</label>'
|
||||||
|
f'<div class="flex items-center"><span class="text-stone-400 mr-1">@</span>'
|
||||||
|
f'<input type="text" name="username" id="username" value="{escape(username)}"'
|
||||||
|
f' pattern="[a-z][a-z0-9_]{{2,31}}" minlength="3" maxlength="32" required autocomplete="off"'
|
||||||
|
f' class="flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
|
||||||
|
f' hx-get="{check_url}" hx-trigger="keyup changed delay:300ms" hx-target="#username-status" hx-include="[name=\'username\']">'
|
||||||
|
f'</div><div id="username-status" class="text-sm mt-1"></div>'
|
||||||
|
f'<p class="text-xs text-stone-400 mt-1">3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.</p></div>'
|
||||||
|
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">Claim username</button></form></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title="Choose Username \u2014 Rose Ash")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: Actor profile
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_profile_page(ctx: dict, actor: Any, activities: list,
|
||||||
|
total: int) -> str:
|
||||||
|
"""Full page: actor profile."""
|
||||||
|
from shared.config import config
|
||||||
|
|
||||||
|
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||||
|
display_name = actor.display_name or actor.preferred_username
|
||||||
|
summary_html = f'<p class="mt-2">{escape(actor.summary)}</p>' if actor.summary else ""
|
||||||
|
|
||||||
|
activities_html = ""
|
||||||
|
if activities:
|
||||||
|
parts = []
|
||||||
|
for a in activities:
|
||||||
|
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
|
||||||
|
obj_type = f'<span class="text-sm text-stone-500">{a.object_type}</span>' if a.object_type else ""
|
||||||
|
parts.append(
|
||||||
|
f'<div class="bg-white rounded-lg shadow p-4"><div class="flex justify-between items-start">'
|
||||||
|
f'<span class="font-medium">{a.activity_type}</span>'
|
||||||
|
f'<span class="text-sm text-stone-400">{published}</span></div>{obj_type}</div>'
|
||||||
|
)
|
||||||
|
activities_html = '<div class="space-y-4">' + "".join(parts) + '</div>'
|
||||||
|
else:
|
||||||
|
activities_html = '<p class="text-stone-500">No activities yet.</p>'
|
||||||
|
|
||||||
|
content = (
|
||||||
|
f'<div class="py-8"><div class="bg-white rounded-lg shadow p-6 mb-6">'
|
||||||
|
f'<h1 class="text-2xl font-bold">{escape(display_name)}</h1>'
|
||||||
|
f'<p class="text-stone-500">@{escape(actor.preferred_username)}@{escape(ap_domain)}</p>'
|
||||||
|
f'{summary_html}</div>'
|
||||||
|
f'<h2 class="text-xl font-bold mb-4">Activities ({total})</h2>{activities_html}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return _social_page(ctx, actor, content_html=content,
|
||||||
|
title=f"@{actor.preferred_username} \u2014 Rose Ash")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ def register() -> Blueprint:
|
|||||||
page=page,
|
page=page,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_all_markets_page, render_all_markets_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
if is_htmx_request():
|
if is_htmx_request():
|
||||||
html = await render_template("_types/all_markets/_main_panel.html", **ctx)
|
html = await render_all_markets_oob(tctx, markets, has_more, page_info, page)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/all_markets/index.html", **ctx)
|
html = await render_all_markets_page(tctx, markets, has_more, page_info, page)
|
||||||
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@@ -67,13 +71,8 @@ def register() -> Blueprint:
|
|||||||
page = int(request.args.get("page", 1))
|
page = int(request.args.get("page", 1))
|
||||||
markets, has_more, page_info = await _load_markets(page)
|
markets, has_more, page_info = await _load_markets(page)
|
||||||
|
|
||||||
html = await render_template(
|
from sexp_components import render_all_markets_cards
|
||||||
"_types/all_markets/_cards.html",
|
html = await render_all_markets_cards(markets, has_more, page_info, page)
|
||||||
markets=markets,
|
|
||||||
has_more=has_more,
|
|
||||||
page_info=page_info,
|
|
||||||
page=page,
|
|
||||||
)
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -42,12 +42,15 @@ def register():
|
|||||||
p_data = getattr(g, "post_data", None) or {}
|
p_data = getattr(g, "post_data", None) or {}
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
# Determine which template to use based on request type
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_market_home_page, render_market_home_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
ctx.update(p_data)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_market_home_page(ctx)
|
||||||
html = await render_template("_types/market/index.html", **p_data)
|
|
||||||
else:
|
else:
|
||||||
# HTMX request: main panel + OOB elements
|
html = await render_market_home_oob(ctx)
|
||||||
html = await render_template("_types/market/_oob_elements.html", **p_data)
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@@ -70,16 +73,18 @@ def register():
|
|||||||
product_info = await _productInfo()
|
product_info = await _productInfo()
|
||||||
full_context = {**product_info, **ctx}
|
full_context = {**product_info, **ctx}
|
||||||
|
|
||||||
# Determine which template to use based on request type and pagination
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_browse_page, render_browse_oob, render_browse_cards
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(full_context)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_browse_page(tctx)
|
||||||
html = await render_template("_types/browse/index.html", **full_context)
|
|
||||||
elif product_info["page"] > 1:
|
elif product_info["page"] > 1:
|
||||||
# HTMX pagination: just product cards + sentinel
|
tctx.update(product_info)
|
||||||
html = await render_template("_types/browse/_product_cards.html", **product_info)
|
html = await render_browse_cards(tctx)
|
||||||
else:
|
else:
|
||||||
# HTMX navigation (page 1): main panel + OOB elements
|
html = await render_browse_oob(tctx)
|
||||||
html = await render_template("_types/browse/_oob_elements.html", **full_context)
|
|
||||||
|
|
||||||
resp = await make_response(html)
|
resp = await make_response(html)
|
||||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||||
@@ -107,15 +112,18 @@ def register():
|
|||||||
product_info = await _productInfo(top_slug)
|
product_info = await _productInfo(top_slug)
|
||||||
full_context = {**product_info, **ctx}
|
full_context = {**product_info, **ctx}
|
||||||
|
|
||||||
# Determine which template to use based on request type and pagination
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_browse_page, render_browse_oob, render_browse_cards
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(full_context)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_browse_page(tctx)
|
||||||
html = await render_template("_types/browse/index.html", **full_context)
|
|
||||||
elif product_info["page"] > 1:
|
elif product_info["page"] > 1:
|
||||||
# HTMX pagination: just product cards + sentinel
|
tctx.update(product_info)
|
||||||
html = await render_template("_types/browse/_product_cards.html", **product_info)
|
html = await render_browse_cards(tctx)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/browse/_oob_elements.html", **full_context)
|
html = await render_browse_oob(tctx)
|
||||||
|
|
||||||
resp = await make_response(html)
|
resp = await make_response(html)
|
||||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||||
@@ -143,16 +151,18 @@ def register():
|
|||||||
product_info = await _productInfo(top_slug, sub_slug)
|
product_info = await _productInfo(top_slug, sub_slug)
|
||||||
full_context = {**product_info, **ctx}
|
full_context = {**product_info, **ctx}
|
||||||
|
|
||||||
# Determine which template to use based on request type and pagination
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_browse_page, render_browse_oob, render_browse_cards
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx.update(full_context)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_browse_page(tctx)
|
||||||
html = await render_template("_types/browse/index.html", **full_context)
|
|
||||||
elif product_info["page"] > 1:
|
elif product_info["page"] > 1:
|
||||||
# HTMX pagination: just product cards + sentinel
|
tctx.update(product_info)
|
||||||
html = await render_template("_types/browse/_product_cards.html", **product_info)
|
html = await render_browse_cards(tctx)
|
||||||
else:
|
else:
|
||||||
# HTMX navigation (page 1): main panel + OOB elements
|
html = await render_browse_oob(tctx)
|
||||||
html = await render_template("_types/browse/_oob_elements.html", **full_context)
|
|
||||||
|
|
||||||
resp = await make_response(html)
|
resp = await make_response(html)
|
||||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ def register():
|
|||||||
async def admin():
|
async def admin():
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_market_admin_page, render_market_admin_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_market_admin_page(tctx)
|
||||||
html = await render_template("_types/market/admin/index.html")
|
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/market/admin/_oob_elements.html")
|
html = await render_market_admin_oob(tctx)
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -39,10 +39,15 @@ def register() -> Blueprint:
|
|||||||
page=page,
|
page=page,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_page_markets_page, render_page_markets_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
tctx["post"] = post
|
||||||
if is_htmx_request():
|
if is_htmx_request():
|
||||||
html = await render_template("_types/page_markets/_main_panel.html", **ctx)
|
html = await render_page_markets_oob(tctx, markets, has_more, page)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/page_markets/index.html", **ctx)
|
html = await render_page_markets_page(tctx, markets, has_more, page)
|
||||||
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@@ -53,13 +58,9 @@ def register() -> Blueprint:
|
|||||||
|
|
||||||
markets, has_more = await _load_markets(post["id"], page)
|
markets, has_more = await _load_markets(post["id"], page)
|
||||||
|
|
||||||
html = await render_template(
|
from sexp_components import render_page_markets_cards
|
||||||
"_types/page_markets/_cards.html",
|
post_slug = post.get("slug", "")
|
||||||
markets=markets,
|
html = await render_page_markets_cards(markets, has_more, page, post_slug)
|
||||||
has_more=has_more,
|
|
||||||
page_info={},
|
|
||||||
page=page,
|
|
||||||
)
|
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -107,13 +107,17 @@ def register():
|
|||||||
async def product_detail():
|
async def product_detail():
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_product_page, render_product_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
item_data = getattr(g, "item_data", {})
|
||||||
|
d = item_data.get("d", {})
|
||||||
|
tctx["liked_by_current_user"] = item_data.get("liked", False)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_product_page(tctx, d)
|
||||||
html = await render_template("_types/product/index.html")
|
|
||||||
else:
|
else:
|
||||||
# HTMX request: main panel + OOB elements
|
html = await render_product_oob(tctx, d)
|
||||||
html = await render_template("_types/product/_oob_elements.html")
|
|
||||||
|
|
||||||
return html
|
return html
|
||||||
|
|
||||||
@@ -151,12 +155,17 @@ def register():
|
|||||||
async def admin():
|
async def admin():
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
|
from sexp_components import render_product_admin_page, render_product_admin_oob
|
||||||
|
|
||||||
|
tctx = await get_template_context()
|
||||||
|
item_data = getattr(g, "item_data", {})
|
||||||
|
d = item_data.get("d", {})
|
||||||
|
tctx["liked_by_current_user"] = item_data.get("liked", False)
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
html = await render_product_admin_page(tctx, d)
|
||||||
html = await render_template("_types/product/admin/index.html")
|
|
||||||
else:
|
else:
|
||||||
# HTMX request: main panel + OOB elements
|
html = await render_product_admin_oob(tctx, d)
|
||||||
html = await render_template("_types/product/admin/_oob_elements.html")
|
|
||||||
|
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
1584
market/sexp_components.py
Normal file
1584
market/sexp_components.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -69,6 +70,10 @@ def create_app() -> "Quart":
|
|||||||
app.jinja_loader,
|
app.jinja_loader,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Load orders-specific s-expression components
|
||||||
|
from sexp_components import load_orders_components
|
||||||
|
load_orders_components()
|
||||||
|
|
||||||
app.register_blueprint(register_fragments())
|
app.register_blueprint(register_fragments())
|
||||||
app.register_blueprint(register_actions())
|
app.register_blueprint(register_actions())
|
||||||
app.register_blueprint(register_data())
|
app.register_blueprint(register_data())
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch
|
|||||||
from shared.config import config
|
from shared.config import config
|
||||||
|
|
||||||
from shared.infrastructure.cart_identity import current_cart_identity
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.sexp.page import get_template_context
|
||||||
from services.check_sumup_status import check_sumup_status
|
from services.check_sumup_status import check_sumup_status
|
||||||
from shared.browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
@@ -46,10 +47,16 @@ def register() -> Blueprint:
|
|||||||
order = result.scalar_one_or_none()
|
order = result.scalar_one_or_none()
|
||||||
if not order:
|
if not order:
|
||||||
return await make_response("Order not found", 404)
|
return await make_response("Order not found", 404)
|
||||||
|
|
||||||
|
from sexp_components import render_order_page, render_order_oob
|
||||||
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
calendar_entries = ctx.get("calendar_entries")
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/order/index.html", order=order)
|
html = await render_order_page(ctx, order, calendar_entries, url_for)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/order/_oob_elements.html", order=order)
|
html = await render_order_oob(ctx, order, calendar_entries, url_for)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
@bp.get("/pay/")
|
@bp.get("/pay/")
|
||||||
|
|||||||
@@ -116,20 +116,30 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
result = await g.s.execute(stmt)
|
result = await g.s.execute(stmt)
|
||||||
orders = result.scalars().all()
|
orders = result.scalars().all()
|
||||||
|
|
||||||
context = {
|
from shared.sexp.page import get_template_context
|
||||||
"orders": orders,
|
from sexp_components import (
|
||||||
"page": page,
|
render_orders_page,
|
||||||
"total_pages": total_pages,
|
render_orders_rows,
|
||||||
"search": search,
|
render_orders_oob,
|
||||||
"search_count": total_count,
|
)
|
||||||
}
|
|
||||||
|
ctx = await get_template_context()
|
||||||
|
qs_fn = makeqs_factory()
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
html = await render_template("_types/orders/index.html", **context)
|
html = await render_orders_page(
|
||||||
|
ctx, orders, page, total_pages, search, total_count,
|
||||||
|
url_for, qs_fn,
|
||||||
|
)
|
||||||
elif page > 1:
|
elif page > 1:
|
||||||
html = await render_template("_types/orders/_rows.html", **context)
|
html = await render_orders_rows(
|
||||||
|
ctx, orders, page, total_pages, url_for, qs_fn,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
html = await render_template("_types/orders/_oob_elements.html", **context)
|
html = await render_orders_oob(
|
||||||
|
ctx, orders, page, total_pages, search, total_count,
|
||||||
|
url_for, qs_fn,
|
||||||
|
)
|
||||||
|
|
||||||
resp = await make_response(html)
|
resp = await make_response(html)
|
||||||
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
resp.headers["Hx-Push-Url"] = _current_url_without_page()
|
||||||
|
|||||||
385
orders/sexp_components.py
Normal file
385
orders/sexp_components.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
Orders service s-expression page components.
|
||||||
|
|
||||||
|
Each function renders a complete page section (full page, OOB, or pagination)
|
||||||
|
using shared s-expression components. Called from route handlers in place
|
||||||
|
of ``render_template()``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from shared.sexp.jinja_bridge import sexp, register_components
|
||||||
|
from shared.sexp.helpers import (
|
||||||
|
call_url, get_asset_url, root_header_html,
|
||||||
|
search_mobile_html, search_desktop_html, full_page, oob_page,
|
||||||
|
)
|
||||||
|
from shared.sexp.page import HAMBURGER_HTML
|
||||||
|
from shared.infrastructure.urls import market_product_url
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service-specific component definitions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_orders_components() -> None:
|
||||||
|
"""Register orders-specific s-expression components (placeholder for future)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Header helpers (shared auth + orders-specific)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _auth_nav_html(ctx: dict) -> str:
|
||||||
|
"""Auth section desktop nav items."""
|
||||||
|
html = sexp(
|
||||||
|
'(~nav-link :href h :label "newsletters" :select-colours sc)',
|
||||||
|
h=call_url(ctx, "account_url", "/newsletters/"),
|
||||||
|
sc=ctx.get("select_colours", ""),
|
||||||
|
)
|
||||||
|
account_nav_html = ctx.get("account_nav_html", "")
|
||||||
|
if account_nav_html:
|
||||||
|
html += account_nav_html
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||||
|
"""Build the account section header row."""
|
||||||
|
return sexp(
|
||||||
|
'(~menu-row :id "auth-row" :level 1 :colour "sky"'
|
||||||
|
' :link-href lh :link-label "account" :icon "fa-solid fa-user"'
|
||||||
|
' :nav-html nh :child-id "auth-header-child" :oob oob)',
|
||||||
|
lh=call_url(ctx, "account_url", "/"),
|
||||||
|
nh=_auth_nav_html(ctx),
|
||||||
|
oob=oob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_header_html(ctx: dict, list_url: str) -> str:
|
||||||
|
"""Build the orders section header row."""
|
||||||
|
return sexp(
|
||||||
|
'(~menu-row :id "orders-row" :level 2 :colour "sky"'
|
||||||
|
' :link-href lh :link-label "Orders" :icon "fa fa-gbp"'
|
||||||
|
' :child-id "orders-header-child")',
|
||||||
|
lh=list_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Orders list rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _order_row_html(order: Any, detail_url: str) -> str:
|
||||||
|
"""Render a single order as desktop table row + mobile card."""
|
||||||
|
status = order.status or "pending"
|
||||||
|
sl = status.lower()
|
||||||
|
pill = (
|
||||||
|
"border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid"
|
||||||
|
else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled")
|
||||||
|
else "border-stone-300 bg-stone-50 text-stone-700"
|
||||||
|
)
|
||||||
|
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||||
|
total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}"
|
||||||
|
|
||||||
|
return (
|
||||||
|
# Desktop row
|
||||||
|
f'<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">'
|
||||||
|
f'<td class="px-3 py-2 align-top"><span class="font-mono text-[11px] sm:text-xs">#{order.id}</span></td>'
|
||||||
|
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{created}</td>'
|
||||||
|
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{order.description or ""}</td>'
|
||||||
|
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{total}</td>'
|
||||||
|
f'<td class="px-3 py-2 align-top"><span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}">{status}</span></td>'
|
||||||
|
f'<td class="px-3 py-0.5 align-top text-right"><a href="{detail_url}" class="inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">View</a></td></tr>'
|
||||||
|
# Mobile row
|
||||||
|
f'<tr class="sm:hidden border-t border-stone-100"><td colspan="5" class="px-3 py-3"><div class="flex flex-col gap-2 text-xs">'
|
||||||
|
f'<div class="flex items-center justify-between gap-2"><span class="font-mono text-[11px] text-stone-700">#{order.id}</span>'
|
||||||
|
f'<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}">{status}</span></div>'
|
||||||
|
f'<div class="text-[11px] text-stone-500 break-words">{created}</div>'
|
||||||
|
f'<div class="flex items-center justify-between gap-2"><div class="font-medium text-stone-800">{total}</div>'
|
||||||
|
f'<a href="{detail_url}" class="inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0">View</a></div></div></td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_rows_html(orders: list, page: int, total_pages: int,
|
||||||
|
url_for_fn: Any, qs_fn: Any) -> str:
|
||||||
|
"""Render order rows + infinite scroll sentinel."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
pfx = route_prefix()
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
_order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id))
|
||||||
|
for o in orders
|
||||||
|
]
|
||||||
|
|
||||||
|
if page < total_pages:
|
||||||
|
next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1)
|
||||||
|
parts.append(sexp(
|
||||||
|
'(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)',
|
||||||
|
u=next_url, p=page, **{"total-pages": total_pages},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
parts.append('<tr><td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">End of results</td></tr>')
|
||||||
|
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
|
||||||
|
"""Main panel with table or empty state."""
|
||||||
|
if not orders:
|
||||||
|
return (
|
||||||
|
'<div class="max-w-full px-3 py-3 space-y-3">'
|
||||||
|
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700">'
|
||||||
|
'No orders yet.</div></div>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<div class="max-w-full px-3 py-3 space-y-3">'
|
||||||
|
'<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">'
|
||||||
|
'<table class="min-w-full text-xs sm:text-sm">'
|
||||||
|
'<thead class="bg-stone-50 border-b border-stone-200 text-stone-600"><tr>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Order</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Created</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Description</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Total</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium">Status</th>'
|
||||||
|
'<th class="px-3 py-2 text-left font-medium"></th>'
|
||||||
|
f'</tr></thead><tbody>{rows_html}</tbody></table></div></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_summary_html(ctx: dict) -> str:
|
||||||
|
"""Filter section for orders list."""
|
||||||
|
return (
|
||||||
|
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
|
||||||
|
'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Recent orders placed via the checkout.</p></div>'
|
||||||
|
f'<div class="md:hidden">{search_mobile_html(ctx)}</div>'
|
||||||
|
'</header>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API: orders list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def render_orders_page(ctx: dict, orders: list, page: int,
|
||||||
|
total_pages: int, search: str | None,
|
||||||
|
search_count: int, url_for_fn: Any,
|
||||||
|
qs_fn: Any) -> str:
|
||||||
|
"""Full page: orders list."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
|
||||||
|
ctx["search"] = search
|
||||||
|
ctx["search_count"] = search_count
|
||||||
|
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||||
|
|
||||||
|
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||||
|
main = _orders_main_panel_html(orders, rows)
|
||||||
|
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a) (raw! o))',
|
||||||
|
a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_page(ctx, header_rows_html=hdr,
|
||||||
|
filter_html=_orders_summary_html(ctx),
|
||||||
|
aside_html=search_desktop_html(ctx),
|
||||||
|
content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_orders_rows(ctx: dict, orders: list, page: int,
|
||||||
|
total_pages: int, url_for_fn: Any,
|
||||||
|
qs_fn: Any) -> str:
|
||||||
|
"""Pagination: just the table rows."""
|
||||||
|
return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_orders_oob(ctx: dict, orders: list, page: int,
|
||||||
|
total_pages: int, search: str | None,
|
||||||
|
search_count: int, url_for_fn: Any,
|
||||||
|
qs_fn: Any) -> str:
|
||||||
|
"""OOB response for HTMX navigation to orders list."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
|
||||||
|
ctx["search"] = search
|
||||||
|
ctx["search_count"] = search_count
|
||||||
|
list_url = route_prefix() + url_for_fn("orders.list_orders")
|
||||||
|
|
||||||
|
rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn)
|
||||||
|
main = _orders_main_panel_html(orders, rows)
|
||||||
|
|
||||||
|
oobs = (
|
||||||
|
_auth_header_html(ctx, oob=True)
|
||||||
|
+ sexp(
|
||||||
|
'(div :id "auth-header-child" :hx-swap-oob "outerHTML"'
|
||||||
|
' :class "flex flex-col w-full items-center" (raw! o))',
|
||||||
|
o=_orders_header_html(ctx, list_url),
|
||||||
|
)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return oob_page(ctx, oobs_html=oobs,
|
||||||
|
filter_html=_orders_summary_html(ctx),
|
||||||
|
aside_html=search_desktop_html(ctx),
|
||||||
|
content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Single order detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _order_items_html(order: Any) -> str:
|
||||||
|
"""Render order items list."""
|
||||||
|
if not order or not order.items:
|
||||||
|
return ""
|
||||||
|
items = []
|
||||||
|
for item in order.items:
|
||||||
|
prod_url = market_product_url(item.product_slug)
|
||||||
|
img = (
|
||||||
|
f'<img src="{item.product_image}" alt="{item.product_title or "Product image"}"'
|
||||||
|
f' class="w-full h-full object-contain object-center" loading="lazy" decoding="async">'
|
||||||
|
if item.product_image else
|
||||||
|
'<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">No image</div>'
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
f'<li><a class="w-full py-2 flex gap-3" href="{prod_url}">'
|
||||||
|
f'<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">{img}</div>'
|
||||||
|
f'<div class="flex-1 flex justify-between gap-3">'
|
||||||
|
f'<div><p class="font-medium">{item.product_title or "Unknown product"}</p>'
|
||||||
|
f'<p class="text-[11px] text-stone-500">Product ID: {item.product_id}</p></div>'
|
||||||
|
f'<div class="text-right whitespace-nowrap"><p>Qty: {item.quantity}</p>'
|
||||||
|
f'<p>{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}</p>'
|
||||||
|
f'</div></div></a></li>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">'
|
||||||
|
'<h2 class="text-sm sm:text-base font-semibold mb-3">Items</h2>'
|
||||||
|
f'<ul class="divide-y divide-stone-100 text-xs sm:text-sm">{"".join(items)}</ul></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_items_html(calendar_entries: list | None) -> str:
|
||||||
|
"""Render calendar bookings for an order."""
|
||||||
|
if not calendar_entries:
|
||||||
|
return ""
|
||||||
|
items = []
|
||||||
|
for e in calendar_entries:
|
||||||
|
st = e.state or ""
|
||||||
|
pill = (
|
||||||
|
"bg-emerald-100 text-emerald-800" if st == "confirmed"
|
||||||
|
else "bg-amber-100 text-amber-800" if st == "provisional"
|
||||||
|
else "bg-blue-100 text-blue-800" if st == "ordered"
|
||||||
|
else "bg-stone-100 text-stone-700"
|
||||||
|
)
|
||||||
|
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
|
||||||
|
if e.end_at:
|
||||||
|
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
|
||||||
|
items.append(
|
||||||
|
f'<li class="px-4 py-3 flex items-start justify-between text-sm">'
|
||||||
|
f'<div><div class="font-medium flex items-center gap-2">{e.name}'
|
||||||
|
f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}">'
|
||||||
|
f'{st.capitalize()}</span></div>'
|
||||||
|
f'<div class="text-xs text-stone-500">{ds}</div></div>'
|
||||||
|
f'<div class="ml-4 font-medium">\u00a3{e.cost or 0:.2f}</div></li>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<section class="mt-6 space-y-3">'
|
||||||
|
'<h2 class="text-base sm:text-lg font-semibold">Calendar bookings in this order</h2>'
|
||||||
|
f'<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">{"".join(items)}</ul></section>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _order_main_html(order: Any, calendar_entries: list | None) -> str:
|
||||||
|
"""Main panel for single order detail."""
|
||||||
|
summary = sexp(
|
||||||
|
'(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)',
|
||||||
|
oid=order.id,
|
||||||
|
ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None,
|
||||||
|
d=order.description, s=order.status, c=order.currency,
|
||||||
|
ta=f"{order.total_amount:.2f}" if order.total_amount else None,
|
||||||
|
)
|
||||||
|
return f'<div class="max-w-full px-3 py-3 space-y-4">{summary}{_order_items_html(order)}{_calendar_items_html(calendar_entries)}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _order_filter_html(order: Any, list_url: str, recheck_url: str,
|
||||||
|
pay_url: str, csrf_token: str) -> str:
|
||||||
|
"""Filter section for single order detail."""
|
||||||
|
created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014"
|
||||||
|
status = order.status or "pending"
|
||||||
|
pay = (
|
||||||
|
f'<a href="{pay_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm '
|
||||||
|
f'rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
|
||||||
|
f'<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>Open payment page</a>'
|
||||||
|
) if status != "paid" else ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
|
||||||
|
f'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Placed {created} · Status: {status}</p></div>'
|
||||||
|
'<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">'
|
||||||
|
f'<a href="{list_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-list mr-2" aria-hidden="true"></i>All orders</a>'
|
||||||
|
f'<form method="post" action="{recheck_url}" class="inline"><input type="hidden" name="csrf_token" value="{csrf_token}">'
|
||||||
|
f'<button type="submit" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>Re-check status</button></form>'
|
||||||
|
f'{pay}</div></header>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_order_page(ctx: dict, order: Any,
|
||||||
|
calendar_entries: list | None,
|
||||||
|
url_for_fn: Any) -> str:
|
||||||
|
"""Full page: single order detail."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
pfx = route_prefix()
|
||||||
|
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||||
|
list_url = pfx + url_for_fn("orders.list_orders")
|
||||||
|
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||||
|
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||||
|
|
||||||
|
main = _order_main_html(order, calendar_entries)
|
||||||
|
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||||
|
|
||||||
|
# Header stack: root -> auth -> orders -> order
|
||||||
|
hdr = root_header_html(ctx)
|
||||||
|
order_row = sexp(
|
||||||
|
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp")',
|
||||||
|
lh=detail_url,
|
||||||
|
)
|
||||||
|
hdr += sexp(
|
||||||
|
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)'
|
||||||
|
' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)'
|
||||||
|
' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))',
|
||||||
|
a=_auth_header_html(ctx),
|
||||||
|
b=_orders_header_html(ctx, list_url),
|
||||||
|
c=order_row,
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_order_oob(ctx: dict, order: Any,
|
||||||
|
calendar_entries: list | None,
|
||||||
|
url_for_fn: Any) -> str:
|
||||||
|
"""OOB response for single order detail."""
|
||||||
|
from shared.utils import route_prefix
|
||||||
|
from shared.browser.app.csrf import generate_csrf_token
|
||||||
|
|
||||||
|
pfx = route_prefix()
|
||||||
|
detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id)
|
||||||
|
list_url = pfx + url_for_fn("orders.list_orders")
|
||||||
|
recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id)
|
||||||
|
pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id)
|
||||||
|
|
||||||
|
main = _order_main_html(order, calendar_entries)
|
||||||
|
filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token())
|
||||||
|
|
||||||
|
order_row_oob = sexp(
|
||||||
|
'(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp" :oob true)',
|
||||||
|
lh=detail_url,
|
||||||
|
)
|
||||||
|
oobs = (
|
||||||
|
sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob)
|
||||||
|
+ root_header_html(ctx, oob=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main)
|
||||||
@@ -16,6 +16,51 @@ from shared.utils import host_url
|
|||||||
from shared.browser.app.utils import current_route_relative_path
|
from shared.browser.app.utils import current_route_relative_path
|
||||||
|
|
||||||
|
|
||||||
|
def _qs_filter_fn():
|
||||||
|
"""Build a qs_filter(dict) wrapper for sexp components, or None.
|
||||||
|
|
||||||
|
Sexp components call ``qs_fn({"page": 2})``, ``qs_fn({"sort": "az"})``,
|
||||||
|
``qs_fn({"labels": ["organic", "local"]})``, etc.
|
||||||
|
|
||||||
|
Simple keys (page, sort, search, liked, clear_filters) are forwarded
|
||||||
|
to ``makeqs(**kwargs)``. List-valued keys (labels, stickers, brands)
|
||||||
|
represent *replacement* sets, so we rebuild the querystring from the
|
||||||
|
current base with those overridden.
|
||||||
|
"""
|
||||||
|
factory = getattr(g, "makeqs_factory", None)
|
||||||
|
if not factory:
|
||||||
|
return None
|
||||||
|
makeqs = factory()
|
||||||
|
|
||||||
|
def _qs(d: dict) -> str:
|
||||||
|
from shared.browser.app.filters.qs_base import build_qs
|
||||||
|
|
||||||
|
# Collect list-valued overrides
|
||||||
|
list_overrides = {}
|
||||||
|
for plural, singular in (("labels", "label"), ("stickers", "sticker"), ("brands", "brand")):
|
||||||
|
if plural in d:
|
||||||
|
list_overrides[singular] = list(d[plural] or [])
|
||||||
|
|
||||||
|
simple = {k: v for k, v in d.items()
|
||||||
|
if k in ("page", "sort", "search", "liked", "clear_filters")}
|
||||||
|
|
||||||
|
if not list_overrides:
|
||||||
|
return makeqs(**simple)
|
||||||
|
|
||||||
|
# For list overrides: get the base qs, parse out the overridden keys,
|
||||||
|
# then rebuild with the new values.
|
||||||
|
base_qs = makeqs(**simple)
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
params = [(k, v) for k, v in parse_qsl(base_qs.lstrip("?"))
|
||||||
|
if k not in list_overrides]
|
||||||
|
for singular, vals in list_overrides.items():
|
||||||
|
for v in vals:
|
||||||
|
params.append((singular, v))
|
||||||
|
return ("?" + urlencode(params)) if params else ""
|
||||||
|
|
||||||
|
return _qs
|
||||||
|
|
||||||
|
|
||||||
async def base_context() -> dict:
|
async def base_context() -> dict:
|
||||||
"""
|
"""
|
||||||
Common template variables available in every app.
|
Common template variables available in every app.
|
||||||
@@ -50,6 +95,7 @@ async def base_context() -> dict:
|
|||||||
("price-desc", "\u00a3 high\u2192low", "order/h-l.svg"),
|
("price-desc", "\u00a3 high\u2192low", "order/h-l.svg"),
|
||||||
],
|
],
|
||||||
"zap_filter": zap_filter,
|
"zap_filter": zap_filter,
|
||||||
|
"qs_filter": _qs_filter_fn(),
|
||||||
"print": print,
|
"print": print,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"base_title": config()["title"],
|
"base_title": config()["title"],
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ def load_shared_components() -> None:
|
|||||||
register_components(_POST_CARD)
|
register_components(_POST_CARD)
|
||||||
register_components(_BASE_SHELL)
|
register_components(_BASE_SHELL)
|
||||||
register_components(_ERROR_PAGE)
|
register_components(_ERROR_PAGE)
|
||||||
|
# Phase 6: layout infrastructure
|
||||||
|
register_components(_APP_SHELL)
|
||||||
|
register_components(_APP_LAYOUT)
|
||||||
|
register_components(_OOB_RESPONSE)
|
||||||
|
register_components(_HEADER_ROW)
|
||||||
|
register_components(_MENU_ROW)
|
||||||
|
register_components(_NAV_LINK)
|
||||||
|
register_components(_INFINITE_SCROLL)
|
||||||
|
register_components(_STATUS_PILL)
|
||||||
|
register_components(_SEARCH_MOBILE)
|
||||||
|
register_components(_SEARCH_DESKTOP)
|
||||||
|
register_components(_ORDER_SUMMARY_CARD)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -298,3 +310,425 @@ _ERROR_PAGE = '''
|
|||||||
(div :class "flex justify-center"
|
(div :class "flex justify-center"
|
||||||
(img :src image :width "300" :height "300"))))))
|
(img :src image :width "300" :height "300"))))))
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Phase 6: Layout infrastructure components
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~app-shell — full HTML document with all required CSS/JS assets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: _types/root/index.html <html><head>...<body> shell
|
||||||
|
#
|
||||||
|
# This includes htmx, hyperscript, tailwind, fontawesome, prism, and
|
||||||
|
# all shared CSS/JS. ``~base-shell`` remains the lightweight error-page
|
||||||
|
# shell; ``~app-shell`` is for real app pages.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_APP_SHELL = r'''
|
||||||
|
(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html)
|
||||||
|
(<>
|
||||||
|
(raw! "<!doctype html>")
|
||||||
|
(html :lang "en"
|
||||||
|
(head
|
||||||
|
(meta :charset "utf-8")
|
||||||
|
(meta :name "viewport" :content "width=device-width, initial-scale=1")
|
||||||
|
(meta :name "robots" :content "index,follow")
|
||||||
|
(meta :name "theme-color" :content "#ffffff")
|
||||||
|
(title title)
|
||||||
|
(when meta-html (raw! meta-html))
|
||||||
|
(style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }")
|
||||||
|
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css"))
|
||||||
|
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css"))
|
||||||
|
(link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css"))
|
||||||
|
(script :src "https://unpkg.com/htmx.org@2.0.8")
|
||||||
|
(script :src "https://unpkg.com/hyperscript.org@0.9.12")
|
||||||
|
(script :src "https://cdn.tailwindcss.com")
|
||||||
|
(link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css"))
|
||||||
|
(link :rel "stylesheet" :href (str 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 :src "https://unpkg.com/prismjs/components/prism-javascript.min.js")
|
||||||
|
(script :src "https://unpkg.com/prismjs/components/prism-python.min.js")
|
||||||
|
(script :src "https://unpkg.com/prismjs/components/prism-bash.min.js")
|
||||||
|
(script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11")
|
||||||
|
(script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}")
|
||||||
|
(script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})")
|
||||||
|
(style
|
||||||
|
"details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}"
|
||||||
|
"details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}"
|
||||||
|
"@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}"
|
||||||
|
".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}"))
|
||||||
|
(body :class "bg-stone-50 text-stone-900"
|
||||||
|
(raw! body-html)
|
||||||
|
(when body-end-html (raw! body-end-html))
|
||||||
|
(script :src (str asset-url "/scripts/body.js"))))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~app-layout — page body layout (header + filter + aside + main-panel)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: _types/root/index.html body structure
|
||||||
|
#
|
||||||
|
# The header uses a <details>/<summary> pattern for mobile menu toggle.
|
||||||
|
# All content sections are passed as pre-rendered HTML strings.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~app-layout :title t :asset-url a :header-rows-html h
|
||||||
|
# :menu-html m :filter-html f :aside-html a :content-html c)', **ctx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_APP_LAYOUT = r'''
|
||||||
|
(defcomp ~app-layout (&key title asset-url meta-html menu-colour
|
||||||
|
header-rows-html menu-html
|
||||||
|
filter-html aside-html content-html
|
||||||
|
body-end-html)
|
||||||
|
(let* ((colour (or menu-colour "sky")))
|
||||||
|
(~app-shell :title (or title "Rose Ash") :asset-url asset-url
|
||||||
|
:meta-html meta-html :body-end-html body-end-html
|
||||||
|
:body-html (str
|
||||||
|
"<div class=\"max-w-screen-2xl mx-auto py-1 px-1\">"
|
||||||
|
"<div class=\"w-full\">"
|
||||||
|
"<details class=\"group/root p-2\" data-toggle-group=\"mobile-panels\">"
|
||||||
|
"<summary>"
|
||||||
|
"<header class=\"z-50\">"
|
||||||
|
"<div id=\"root-header-summary\" class=\"flex items-start gap-2 p-1 bg-" colour "-500\">"
|
||||||
|
"<div class=\"flex flex-col w-full items-center\">"
|
||||||
|
header-rows-html
|
||||||
|
"</div>"
|
||||||
|
"</div>"
|
||||||
|
"</header>"
|
||||||
|
"</summary>"
|
||||||
|
"<div id=\"root-menu\" hx-swap-oob=\"outerHTML\" class=\"md:hidden\">"
|
||||||
|
(or menu-html "")
|
||||||
|
"</div>"
|
||||||
|
"</details>"
|
||||||
|
"</div>"
|
||||||
|
"<div id=\"filter\">"
|
||||||
|
(or filter-html "")
|
||||||
|
"</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\">"
|
||||||
|
(or aside-html "")
|
||||||
|
"</aside>"
|
||||||
|
"<section id=\"main-panel\" class=\"flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport\">"
|
||||||
|
(or content-html "")
|
||||||
|
"<div class=\"pb-8\"></div>"
|
||||||
|
"</section>"
|
||||||
|
"</div>"
|
||||||
|
"</div>"
|
||||||
|
"</main>"
|
||||||
|
"</div>"))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~oob-response — HTMX OOB multi-target swap wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: oob_elements.html base template
|
||||||
|
#
|
||||||
|
# Each named region gets hx-swap-oob="outerHTML" on its wrapper div.
|
||||||
|
# The oobs-html param contains any extra OOB elements (header row swaps).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~oob-response :oobs-html oh :filter-html fh :aside-html ah
|
||||||
|
# :menu-html mh :content-html ch)', **ctx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_OOB_RESPONSE = '''
|
||||||
|
(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html)
|
||||||
|
(<>
|
||||||
|
(when oobs-html (raw! oobs-html))
|
||||||
|
(div :id "filter" :hx-swap-oob "outerHTML"
|
||||||
|
(when filter-html (raw! filter-html)))
|
||||||
|
(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"
|
||||||
|
(when aside-html (raw! aside-html)))
|
||||||
|
(div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden"
|
||||||
|
(when menu-html (raw! menu-html)))
|
||||||
|
(section :id "main-panel"
|
||||||
|
:class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"
|
||||||
|
(when content-html (raw! content-html)))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~header-row — root header bar (cart-mini, title, nav-tree, auth-menu)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: _types/root/header/_header.html header_row macro
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~header-row :cart-mini-html cm :blog-url bu :site-title st
|
||||||
|
# :nav-tree-html nh :auth-menu-html ah :nav-panel-html np
|
||||||
|
# :settings-url su :is-admin ia)', **ctx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_HEADER_ROW = '''
|
||||||
|
(defcomp ~header-row (&key cart-mini-html blog-url site-title
|
||||||
|
nav-tree-html auth-menu-html nav-panel-html
|
||||||
|
settings-url is-admin oob hamburger-html)
|
||||||
|
(<>
|
||||||
|
(div :id "root-row"
|
||||||
|
:hx-swap-oob (if oob "outerHTML" nil)
|
||||||
|
:class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500"
|
||||||
|
(div :class "w-full flex flex-row items-top"
|
||||||
|
(when cart-mini-html (raw! cart-mini-html))
|
||||||
|
(div :class "font-bold text-5xl flex-1"
|
||||||
|
(a :href (str (or blog-url "") "/") :class "flex justify-center md:justify-start"
|
||||||
|
(h1 (or site-title ""))))
|
||||||
|
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||||
|
(when nav-tree-html (raw! nav-tree-html))
|
||||||
|
(when auth-menu-html (raw! auth-menu-html))
|
||||||
|
(when nav-panel-html (raw! nav-panel-html))
|
||||||
|
(when (and is-admin settings-url)
|
||||||
|
(a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
|
||||||
|
(i :class "fa fa-cog" :aria-hidden "true"))))
|
||||||
|
(when hamburger-html (raw! hamburger-html))))
|
||||||
|
(div :class "block md:hidden text-md font-bold"
|
||||||
|
(when auth-menu-html (raw! auth-menu-html)))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~menu-row — section header row (wraps in colored bar)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: macros/links.html menu_row macro
|
||||||
|
#
|
||||||
|
# Each nested header row gets a progressively lighter background.
|
||||||
|
# The route handler passes the level (0-based depth after root).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~menu-row :id "auth-row" :level 1 :colour "sky"
|
||||||
|
# :link-href url :link-label "account" :icon "fa-solid fa-user"
|
||||||
|
# :nav-html nh :child-id "auth-header-child" :child-html ch)', **ctx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MENU_ROW = '''
|
||||||
|
(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon
|
||||||
|
hx-select nav-html child-id child-html oob)
|
||||||
|
(let* ((c (or colour "sky"))
|
||||||
|
(lv (or level 1))
|
||||||
|
(shade (str (- 500 (* lv 100)))))
|
||||||
|
(<>
|
||||||
|
(div :id id
|
||||||
|
:hx-swap-oob (if oob "outerHTML" nil)
|
||||||
|
:class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade)
|
||||||
|
(div :class "relative nav-group"
|
||||||
|
(a :href link-href
|
||||||
|
:hx-get link-href
|
||||||
|
:hx-target "#main-panel"
|
||||||
|
:hx-select (or hx-select "#main-panel")
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-push-url "true"
|
||||||
|
:class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2"
|
||||||
|
(when icon (i :class icon :aria-hidden "true"))
|
||||||
|
(if link-label-html (raw! link-label-html)
|
||||||
|
(when link-label (div link-label)))))
|
||||||
|
(when nav-html
|
||||||
|
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||||
|
(raw! nav-html))))
|
||||||
|
(when child-id
|
||||||
|
(div :id child-id :class "flex flex-col w-full items-center"
|
||||||
|
(when child-html (raw! child-html)))))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~nav-link — HTMX navigation link (replaces macros/links.html link macro)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_NAV_LINK = '''
|
||||||
|
(defcomp ~nav-link (&key href hx-select label icon aclass select-colours)
|
||||||
|
(div :class "relative nav-group"
|
||||||
|
(a :href href
|
||||||
|
:hx-get href
|
||||||
|
:hx-target "#main-panel"
|
||||||
|
:hx-select (or hx-select "#main-panel")
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-push-url "true"
|
||||||
|
:class (or aclass
|
||||||
|
(str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 "
|
||||||
|
(or select-colours "")))
|
||||||
|
(when icon (i :class icon :aria-hidden "true"))
|
||||||
|
(when label (span label)))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~infinite-scroll — pagination sentinel for table-based lists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: sentinel pattern in _rows.html templates
|
||||||
|
#
|
||||||
|
# For table rows (orders, etc.): renders <tr> with intersection observer.
|
||||||
|
# Uses hyperscript for retry with exponential backoff.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~infinite-scroll :url next-url :page p :total-pages tp
|
||||||
|
# :id-prefix "orders" :colspan 5)', **ctx)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_INFINITE_SCROLL = r'''
|
||||||
|
(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan)
|
||||||
|
(if (< page total-pages)
|
||||||
|
(raw! (str
|
||||||
|
"<tr id=\"" id-prefix "-sentinel-" page "\""
|
||||||
|
" hx-get=\"" url "\""
|
||||||
|
" 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 sentinel:retry "
|
||||||
|
"remove .hidden from .js-loading in me "
|
||||||
|
"add .hidden to .js-neterr in me "
|
||||||
|
"set me.style.pointerEvents to 'none' "
|
||||||
|
"set me.style.opacity to '0' "
|
||||||
|
"trigger htmx:consume on me "
|
||||||
|
"call htmx.trigger(me, 'intersect') "
|
||||||
|
"end "
|
||||||
|
"def backoff() "
|
||||||
|
"add .hidden to .js-loading in me "
|
||||||
|
"remove .hidden from .js-neterr in me "
|
||||||
|
"set myMs to Number(me.dataset.retryMs) "
|
||||||
|
"if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end "
|
||||||
|
"js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs) "
|
||||||
|
"end "
|
||||||
|
"on htmx:beforeRequest "
|
||||||
|
"set me.style.pointerEvents to 'none' "
|
||||||
|
"set me.style.opacity to '0' "
|
||||||
|
"end "
|
||||||
|
"on htmx:afterSwap set me.dataset.retryMs to 1000 end "
|
||||||
|
"on htmx:sendError call backoff() "
|
||||||
|
"on htmx:responseError call backoff() "
|
||||||
|
"on htmx:timeout call backoff()"
|
||||||
|
"\""
|
||||||
|
" role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">"
|
||||||
|
"<td colspan=\"" colspan "\" class=\"px-3 py-4\">"
|
||||||
|
"<div class=\"block md:hidden h-[60vh] js-mobile-sentinel\">"
|
||||||
|
"<div class=\"js-loading text-center text-xs text-stone-400\">loading… " page " / " total-pages "</div>"
|
||||||
|
"<div class=\"js-neterr hidden flex h-full items-center justify-center\"></div>"
|
||||||
|
"</div>"
|
||||||
|
"<div class=\"hidden md:block h-[30vh] js-desktop-sentinel\">"
|
||||||
|
"<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>"
|
||||||
|
"</div>"
|
||||||
|
"</td></tr>"))
|
||||||
|
(raw! (str
|
||||||
|
"<tr><td colspan=\"" colspan "\" class=\"px-3 py-4 text-center text-xs text-stone-400\">End of results</td></tr>"))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~status-pill — colored status indicator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Replaces: inline Jinja status pill patterns across templates
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sexp('(~status-pill :status s :size "sm")', status="paid")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_STATUS_PILL = '''
|
||||||
|
(defcomp ~status-pill (&key status size)
|
||||||
|
(let* ((s (or status "pending"))
|
||||||
|
(lower (lower s))
|
||||||
|
(sz (or size "xs"))
|
||||||
|
(colours (cond
|
||||||
|
(= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||||
|
(= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||||
|
(= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700"
|
||||||
|
(or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700"
|
||||||
|
(= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700"
|
||||||
|
(= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700"
|
||||||
|
true "border-stone-300 bg-stone-50 text-stone-700")))
|
||||||
|
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours)
|
||||||
|
s)))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~search-mobile — mobile search input with htmx
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SEARCH_MOBILE = '''
|
||||||
|
(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile)
|
||||||
|
(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 true
|
||||||
|
:value (or search "")
|
||||||
|
:placeholder "search"
|
||||||
|
:hx-trigger "input changed delay:300ms"
|
||||||
|
:hx-target "#main-panel"
|
||||||
|
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||||
|
:hx-get current-local-href
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-push-url "true"
|
||||||
|
:hx-headers search-headers-mobile
|
||||||
|
:hx-sync "this:replace"
|
||||||
|
:autocomplete "off")
|
||||||
|
(div :id "search-count-mobile" :aria-label "search count"
|
||||||
|
:class (if (not search-count) "text-xl text-red-500" "")
|
||||||
|
(when search (raw! (str search-count))))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~search-desktop — desktop search input with htmx
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SEARCH_DESKTOP = '''
|
||||||
|
(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop)
|
||||||
|
(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 true
|
||||||
|
:value (or search "")
|
||||||
|
:placeholder "search"
|
||||||
|
:hx-trigger "input changed delay:300ms"
|
||||||
|
:hx-target "#main-panel"
|
||||||
|
:hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper")
|
||||||
|
:hx-get current-local-href
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-push-url "true"
|
||||||
|
:hx-headers search-headers-desktop
|
||||||
|
:hx-sync "this:replace"
|
||||||
|
:autocomplete "off")
|
||||||
|
(div :id "search-count-desktop" :aria-label "search count"
|
||||||
|
:class (if (not search-count) "text-xl text-red-500" "")
|
||||||
|
(when search (raw! (str search-count))))))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ~order-summary-card — reusable order summary card
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_ORDER_SUMMARY_CARD = r'''
|
||||||
|
(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount)
|
||||||
|
(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800"
|
||||||
|
(p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id)))
|
||||||
|
(p (span :class "font-medium" "Created:") " " (or created-at "\u2014"))
|
||||||
|
(p (span :class "font-medium" "Description:") " " (or description "\u2013"))
|
||||||
|
(p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]"))
|
||||||
|
(p (span :class "font-medium" "Currency:") " " (or currency "GBP"))
|
||||||
|
(p (span :class "font-medium" "Total:") " "
|
||||||
|
(if total-amount
|
||||||
|
(str (or currency "GBP") " " total-amount)
|
||||||
|
"\u2013"))))
|
||||||
|
'''
|
||||||
|
|||||||
108
shared/sexp/helpers.py
Normal file
108
shared/sexp/helpers.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Shared helper functions for s-expression page rendering.
|
||||||
|
|
||||||
|
These are used by per-service sexp_components.py files to build common
|
||||||
|
page elements (headers, search, etc.) from template context.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .jinja_bridge import sexp
|
||||||
|
from .page import HAMBURGER_HTML, SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||||
|
|
||||||
|
|
||||||
|
def call_url(ctx: dict, key: str, path: str = "/") -> str:
|
||||||
|
"""Call a URL helper from context (e.g., blog_url, account_url)."""
|
||||||
|
fn = ctx.get(key)
|
||||||
|
if callable(fn):
|
||||||
|
return fn(path)
|
||||||
|
return str(fn or "") + path
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_url(ctx: dict) -> str:
|
||||||
|
"""Extract the asset URL base from context."""
|
||||||
|
au = ctx.get("asset_url")
|
||||||
|
if callable(au):
|
||||||
|
result = au("")
|
||||||
|
return result.rsplit("/", 1)[0] if "/" in result else result
|
||||||
|
return au or ""
|
||||||
|
|
||||||
|
|
||||||
|
def root_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||||
|
"""Build the root header row HTML."""
|
||||||
|
return sexp(
|
||||||
|
'(~header-row :cart-mini-html cmi :blog-url bu :site-title st'
|
||||||
|
' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph'
|
||||||
|
' :hamburger-html hh :oob oob)',
|
||||||
|
cmi=ctx.get("cart_mini_html", ""),
|
||||||
|
bu=call_url(ctx, "blog_url", ""),
|
||||||
|
st=ctx.get("base_title", ""),
|
||||||
|
nth=ctx.get("nav_tree_html", ""),
|
||||||
|
amh=ctx.get("auth_menu_html", ""),
|
||||||
|
nph=ctx.get("nav_panel_html", ""),
|
||||||
|
hh=HAMBURGER_HTML,
|
||||||
|
oob=oob,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_mobile_html(ctx: dict) -> str:
|
||||||
|
"""Build mobile search input HTML."""
|
||||||
|
return sexp(
|
||||||
|
'(~search-mobile :current-local-href clh :search s :search-count sc'
|
||||||
|
' :hx-select hs :search-headers-mobile shm)',
|
||||||
|
clh=ctx.get("current_local_href", "/"),
|
||||||
|
s=ctx.get("search", ""),
|
||||||
|
sc=ctx.get("search_count", ""),
|
||||||
|
hs=ctx.get("hx_select", "#main-panel"),
|
||||||
|
shm=SEARCH_HEADERS_MOBILE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_desktop_html(ctx: dict) -> str:
|
||||||
|
"""Build desktop search input HTML."""
|
||||||
|
return sexp(
|
||||||
|
'(~search-desktop :current-local-href clh :search s :search-count sc'
|
||||||
|
' :hx-select hs :search-headers-desktop shd)',
|
||||||
|
clh=ctx.get("current_local_href", "/"),
|
||||||
|
s=ctx.get("search", ""),
|
||||||
|
sc=ctx.get("search_count", ""),
|
||||||
|
hs=ctx.get("hx_select", "#main-panel"),
|
||||||
|
shd=SEARCH_HEADERS_DESKTOP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def full_page(ctx: dict, *, header_rows_html: str,
|
||||||
|
filter_html: str = "", aside_html: str = "",
|
||||||
|
content_html: str = "", menu_html: str = "",
|
||||||
|
body_end_html: str = "", meta_html: str = "") -> str:
|
||||||
|
"""Render a full app page with the standard layout."""
|
||||||
|
return sexp(
|
||||||
|
'(~app-layout :title t :asset-url au :meta-html mh'
|
||||||
|
' :header-rows-html hrh :menu-html muh :filter-html fh'
|
||||||
|
' :aside-html ash :content-html ch :body-end-html beh)',
|
||||||
|
t=ctx.get("base_title", "Rose Ash"),
|
||||||
|
au=get_asset_url(ctx),
|
||||||
|
mh=meta_html,
|
||||||
|
hrh=header_rows_html,
|
||||||
|
muh=menu_html,
|
||||||
|
fh=filter_html,
|
||||||
|
ash=aside_html,
|
||||||
|
ch=content_html,
|
||||||
|
beh=body_end_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def oob_page(ctx: dict, *, oobs_html: str = "",
|
||||||
|
filter_html: str = "", aside_html: str = "",
|
||||||
|
content_html: str = "", menu_html: str = "") -> str:
|
||||||
|
"""Render an OOB response with standard swap targets."""
|
||||||
|
return sexp(
|
||||||
|
'(~oob-response :oobs-html oh :filter-html fh :aside-html ash'
|
||||||
|
' :menu-html mh :content-html ch)',
|
||||||
|
oh=oobs_html,
|
||||||
|
fh=filter_html,
|
||||||
|
ash=aside_html,
|
||||||
|
mh=menu_html,
|
||||||
|
ch=content_html,
|
||||||
|
)
|
||||||
@@ -5,15 +5,23 @@ Provides ``render_page()`` for rendering a complete HTML page from an
|
|||||||
s-expression, bypassing Jinja entirely. Used by error handlers and
|
s-expression, bypassing Jinja entirely. Used by error handlers and
|
||||||
(eventually) by route handlers for fully-migrated pages.
|
(eventually) by route handlers for fully-migrated pages.
|
||||||
|
|
||||||
|
``render_sexp_response()`` is the main entry point for GET route handlers:
|
||||||
|
it calls the app's context processor, merges in route-specific kwargs,
|
||||||
|
renders the s-expression to HTML, and returns a Quart ``Response``.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
from shared.sexp.page import render_page
|
from shared.sexp.page import render_page, render_sexp_response
|
||||||
|
|
||||||
|
# Error pages (no context needed)
|
||||||
html = render_page(
|
html = render_page(
|
||||||
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
|
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
|
||||||
image="/static/errors/404.gif",
|
image="/static/errors/404.gif",
|
||||||
asset_url="/static",
|
asset_url="/static",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# GET route handlers (auto-injects app context)
|
||||||
|
resp = await render_sexp_response('(~orders-page :orders orders)', orders=orders)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,6 +30,22 @@ from typing import Any
|
|||||||
|
|
||||||
from .jinja_bridge import sexp
|
from .jinja_bridge import sexp
|
||||||
|
|
||||||
|
# HTML constants used by layout components — kept here to avoid
|
||||||
|
# s-expression parser issues with embedded quotes in SVG.
|
||||||
|
HAMBURGER_HTML = (
|
||||||
|
'<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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
|
||||||
|
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
|
||||||
|
|
||||||
|
|
||||||
def render_page(source: str, **kwargs: Any) -> str:
|
def render_page(source: str, **kwargs: Any) -> str:
|
||||||
"""Render a full HTML page from an s-expression string.
|
"""Render a full HTML page from an s-expression string.
|
||||||
@@ -30,3 +54,52 @@ def render_page(source: str, **kwargs: Any) -> str:
|
|||||||
intent explicit in call sites (rendering a whole page, not a fragment).
|
intent explicit in call sites (rendering a whole page, not a fragment).
|
||||||
"""
|
"""
|
||||||
return sexp(source, **kwargs)
|
return sexp(source, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_template_context(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
"""Gather the full template context from all registered context processors.
|
||||||
|
|
||||||
|
Returns a dict with all context variables that would normally be
|
||||||
|
available in a Jinja template, merged with any extra kwargs.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from quart import current_app, request
|
||||||
|
|
||||||
|
ctx: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# App-level context processors
|
||||||
|
for proc in current_app.template_context_processors.get(None, []):
|
||||||
|
rv = proc()
|
||||||
|
if asyncio.iscoroutine(rv):
|
||||||
|
rv = await rv
|
||||||
|
ctx.update(rv)
|
||||||
|
|
||||||
|
# Blueprint-scoped context processors
|
||||||
|
for bp_name in (request.blueprints or []):
|
||||||
|
for proc in current_app.template_context_processors.get(bp_name, []):
|
||||||
|
rv = proc()
|
||||||
|
if asyncio.iscoroutine(rv):
|
||||||
|
rv = await rv
|
||||||
|
ctx.update(rv)
|
||||||
|
|
||||||
|
# Inject Jinja globals that s-expression components need (URL helpers,
|
||||||
|
# asset_url, site, etc.) — these aren't provided by context processors.
|
||||||
|
for key, val in current_app.jinja_env.globals.items():
|
||||||
|
if key not in ctx and callable(val):
|
||||||
|
ctx[key] = val
|
||||||
|
|
||||||
|
ctx.update(kwargs)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
async def render_sexp_response(source: str, **kwargs: Any) -> str:
|
||||||
|
"""Render an s-expression with the full app template context.
|
||||||
|
|
||||||
|
Calls the app's registered context processors (which provide
|
||||||
|
cart_mini_html, auth_menu_html, nav_tree_html, asset_url, etc.)
|
||||||
|
and merges them with the caller's kwargs before rendering.
|
||||||
|
|
||||||
|
Returns the rendered HTML string (caller wraps in Response as needed).
|
||||||
|
"""
|
||||||
|
ctx = await get_template_context(**kwargs)
|
||||||
|
return sexp(source, **ctx)
|
||||||
|
|||||||
Reference in New Issue
Block a user