Send all responses as sexp wire format with client-side rendering
- Server sends sexp source text, client (sexp.js) renders everything - SexpExpr marker class for nested sexp composition in serialize() - sexp_page() HTML shell with data-mount="body" for full page loads - sexp_response() returns text/sexp for OOB/partial responses - ~app-body layout component replaces ~app-layout (no raw!) - ~rich-text is the only component using raw! (for CMS HTML content) - Fragment endpoints return text/sexp, auto-wrapped in SexpExpr - All _*_html() helpers converted to _*_sexp() returning sexp source - Head auto-hoist: sexp.js moves meta/title/link/script[ld+json] from rendered body to document.head automatically - Unknown components render warning box instead of crashing page - Component kwargs preserve AST for lazy rendering (fixes <> in kwargs) - Fix unterminated paren in events/sexp/tickets.sexpr Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,8 +83,6 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
|
||||
return None
|
||||
|
||||
try:
|
||||
from shared.sexp.helpers import full_page
|
||||
|
||||
# Build a minimal context — avoid get_template_context() which
|
||||
# calls cross-service fragment fetches that may fail.
|
||||
from shared.infrastructure.context import base_context
|
||||
@@ -110,18 +108,18 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
|
||||
cart_params["user_id"] = ident["user_id"]
|
||||
if ident["session_id"] is not None:
|
||||
cart_params["session_id"] = ident["session_id"]
|
||||
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
|
||||
cart_mini, auth_menu, nav_tree = await fetch_fragments([
|
||||
("cart", "cart-mini", cart_params or None),
|
||||
("account", "auth-menu", {"email": user.email} if user else None),
|
||||
("blog", "nav-tree", {"app_name": current_app.name, "path": request.path}),
|
||||
])
|
||||
ctx["cart_mini_html"] = cart_mini_html
|
||||
ctx["auth_menu_html"] = auth_menu_html
|
||||
ctx["nav_tree_html"] = nav_tree_html
|
||||
ctx["cart_mini"] = cart_mini
|
||||
ctx["auth_menu"] = auth_menu
|
||||
ctx["nav_tree"] = nav_tree
|
||||
except Exception:
|
||||
ctx.setdefault("cart_mini_html", "")
|
||||
ctx.setdefault("auth_menu_html", "")
|
||||
ctx.setdefault("nav_tree_html", "")
|
||||
ctx.setdefault("cart_mini", "")
|
||||
ctx.setdefault("auth_menu", "")
|
||||
ctx.setdefault("nav_tree", "")
|
||||
|
||||
# Try to hydrate post data from slug if not already available
|
||||
segments = [s for s in request.path.strip("/").split("/") if s]
|
||||
@@ -150,23 +148,23 @@ async def _rich_error_page(errnum: str, message: str, image: str | None = None)
|
||||
|
||||
# Root header (site nav bar)
|
||||
from shared.sexp.helpers import (
|
||||
root_header_html, post_header_html,
|
||||
header_child_html, error_content_html,
|
||||
root_header_sexp, post_header_sexp,
|
||||
header_child_sexp, full_page_sexp, sexp_call,
|
||||
)
|
||||
hdr = root_header_html(ctx)
|
||||
hdr = root_header_sexp(ctx)
|
||||
|
||||
# Post breadcrumb if we resolved a post
|
||||
post = (post_data or {}).get("post") or ctx.get("post") or {}
|
||||
if post.get("slug"):
|
||||
ctx["post"] = post
|
||||
post_row = post_header_html(ctx)
|
||||
post_row = post_header_sexp(ctx)
|
||||
if post_row:
|
||||
hdr += header_child_html(post_row)
|
||||
hdr = "(<> " + hdr + " " + header_child_sexp(post_row) + ")"
|
||||
|
||||
# Error content
|
||||
error_html = error_content_html(errnum, message, image)
|
||||
error_content = sexp_call("error-content", errnum=errnum, message=message, image=image)
|
||||
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=error_html)
|
||||
return full_page_sexp(ctx, header_rows=hdr, content=error_content)
|
||||
except Exception:
|
||||
current_app.logger.debug("Rich error page failed, falling back", exc_info=True)
|
||||
return None
|
||||
@@ -245,7 +243,7 @@ def errors(app):
|
||||
status = getattr(e, "status_code", 400)
|
||||
messages = getattr(e, "messages", [str(e)])
|
||||
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
items = "".join(
|
||||
render_comp("error-list-item", message=str(escape(m)))
|
||||
@@ -267,7 +265,7 @@ def errors(app):
|
||||
msg = str(e)
|
||||
# Extract service name from "Fragment account/auth-menu failed: ..."
|
||||
service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown"
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
||||
from shared.sexp.jinja_bridge import render as render_comp
|
||||
return await make_response(
|
||||
render_comp("fragment-error", service=str(escape(service))),
|
||||
@@ -287,7 +285,7 @@ def errors(app):
|
||||
if isinstance(e, HTTPException):
|
||||
status = e.code or 500
|
||||
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
if request.headers.get("SX-Request") == "true" or request.headers.get("HX-Request") == "true":
|
||||
return await make_response(
|
||||
"Something went wrong. Please try again.",
|
||||
status,
|
||||
|
||||
@@ -75,7 +75,7 @@ def make_cache_key(cache_user_id: str) -> str:
|
||||
qs = request.query_string.decode() if request.query_string else ""
|
||||
|
||||
# Check if this is an HTMX request
|
||||
is_htmx = request.headers.get("HX-Request", "").lower() == "true"
|
||||
is_htmx = request.headers.get("SX-Request", "").lower() == "true" or request.headers.get("HX-Request", "").lower() == "true"
|
||||
htmx_suffix = ":htmx" if is_htmx else ""
|
||||
|
||||
if qs:
|
||||
|
||||
@@ -1,46 +1,28 @@
|
||||
"""HTMX utilities for detecting and handling HTMX requests."""
|
||||
"""SxEngine request detection utilities."""
|
||||
|
||||
from quart import request
|
||||
|
||||
|
||||
def is_htmx_request() -> bool:
|
||||
"""
|
||||
Check if the current request is an HTMX request.
|
||||
"""Check if the current request is an SxEngine request."""
|
||||
return request.headers.get("SX-Request", "").lower() == "true"
|
||||
|
||||
Returns:
|
||||
bool: True if HX-Request header is present and true
|
||||
"""
|
||||
return request.headers.get("HX-Request", "").lower() == "true"
|
||||
|
||||
def is_sx_request() -> bool:
|
||||
"""Check if the current request is an SxEngine request."""
|
||||
return request.headers.get("SX-Request", "").lower() == "true"
|
||||
|
||||
|
||||
def get_htmx_target() -> str | None:
|
||||
"""
|
||||
Get the target element ID from HTMX request headers.
|
||||
|
||||
Returns:
|
||||
str | None: Target element ID or None
|
||||
"""
|
||||
return request.headers.get("HX-Target")
|
||||
"""Get the target element ID from SX request headers."""
|
||||
return request.headers.get("SX-Target")
|
||||
|
||||
|
||||
def get_htmx_trigger() -> str | None:
|
||||
"""
|
||||
Get the trigger element ID from HTMX request headers.
|
||||
|
||||
Returns:
|
||||
str | None: Trigger element ID or None
|
||||
"""
|
||||
"""Get the trigger element ID from request headers."""
|
||||
return request.headers.get("HX-Trigger")
|
||||
|
||||
|
||||
def should_return_fragment() -> bool:
|
||||
"""
|
||||
Determine if we should return a fragment vs full page.
|
||||
|
||||
For HTMX requests, return fragment.
|
||||
For normal requests, return full page.
|
||||
|
||||
Returns:
|
||||
bool: True if fragment should be returned
|
||||
"""
|
||||
"""Determine if we should return a fragment vs full page."""
|
||||
return is_htmx_request()
|
||||
|
||||
@@ -41,7 +41,7 @@ def vary(resp: Response) -> Response:
|
||||
"""
|
||||
v = resp.headers.get("Vary", "")
|
||||
parts = [p.strip() for p in v.split(",") if p.strip()]
|
||||
for h in ("HX-Request", "X-Origin"):
|
||||
for h in ("SX-Request", "HX-Request", "X-Origin"):
|
||||
if h not in parts:
|
||||
parts.append(h)
|
||||
if parts:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<button
|
||||
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
|
||||
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
hx-swap-settle="0ms"
|
||||
sx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
|
||||
sx-target="this"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="false"
|
||||
sx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
sx-swap-settle="0ms"
|
||||
{% if liked %}
|
||||
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
|
||||
{% else %}
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/cards.css')}}">
|
||||
<link rel="stylesheet" type="text/css" href="{{asset_url('styles/blog-content.css')}}">
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<meta name="htmx-config" content='{"selfRequestsOnly":false}'>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="{{asset_url('fontawesome/css/all.min.css')}}">
|
||||
<link rel="stylesheet" href="{{asset_url('fontawesome/css/v4-shims.min.css')}}">
|
||||
@@ -59,8 +57,10 @@
|
||||
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; }
|
||||
.sx-indicator { display: none; }
|
||||
.sx-request .sx-indicator { display: inline-flex; }
|
||||
.sx-error .sx-indicator { display: none; }
|
||||
.sx-loading .sx-indicator { display: inline-flex; }
|
||||
</style>
|
||||
<style>
|
||||
.js-wrap.open .js-pop { display:block; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% macro header(id=False, oob=False) %}
|
||||
<div
|
||||
{% if id %}id="{{id}}"{% endif %}
|
||||
{% if oob %}hx-swap-oob="outerHTML"{% endif %}
|
||||
{% if oob %}sx-swap-oob="outerHTML"{% endif %}
|
||||
class="w-full"
|
||||
>
|
||||
{{ caller() }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Shared mobile menu for both base templates and OOB updates
|
||||
|
||||
This macro can be used in two modes:
|
||||
- oob=true: Outputs full wrapper with hx-swap-oob attribute (for OOB updates)
|
||||
- oob=true: Outputs full wrapper with sx-swap-oob attribute (for OOB updates)
|
||||
- oob=false: Outputs just content, assumes wrapper exists (for base templates)
|
||||
|
||||
The caller can pass section-specific nav items via section_nav parameter.
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
{% macro mobile_menu(section_nav='', oob=true) %}
|
||||
{% if oob %}
|
||||
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
|
||||
<div id="root-menu" sx-swap-oob="outerHTML" class="md:hidden">
|
||||
{% endif %}
|
||||
<nav id="nav-panel" {% if oob %}hx-swap-oob="true"{% endif %} class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
|
||||
<nav id="nav-panel" {% if oob %}sx-swap-oob="true"{% endif %} class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
|
||||
{% if not g.user %}
|
||||
{% include '_types/root/_sign_in.html' %}
|
||||
{% endif %}
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
|
||||
{% macro oob_mobile_menu() %}
|
||||
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
|
||||
<div id="root-menu" sx-swap-oob="outerHTML" class="md:hidden">
|
||||
<nav id="nav-panel" class="flex flex-col gap-2 mt-2 px-2 pb-2" role="listbox">
|
||||
{{caller()}}
|
||||
</nav>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Shared root header for both base templates and OOB updates
|
||||
|
||||
This macro can be used in two modes:
|
||||
- oob=true: Outputs full div with hx-swap-oob attribute (for OOB updates)
|
||||
- oob=true: Outputs full div with sx-swap-oob attribute (for OOB updates)
|
||||
- oob=false: Outputs just content, assumes wrapper div exists (for base templates)
|
||||
|
||||
Usage:
|
||||
@@ -19,7 +19,7 @@
|
||||
"%}
|
||||
|
||||
{% if oob %}
|
||||
<div id="root-header" hx-swap-oob="outerHTML" class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
|
||||
<div id="root-header" sx-swap-oob="outerHTML" class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
|
||||
{% endif %}
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
<div class="flex w-full justify-center md:justify-start">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Shared root header for both base templates and OOB updates
|
||||
|
||||
This macro can be used in two modes:
|
||||
- oob=true: Outputs full div with hx-swap-oob attribute (for OOB updates)
|
||||
- oob=true: Outputs full div with sx-swap-oob attribute (for OOB updates)
|
||||
- oob=false: Outputs just content, assumes wrapper div exists (for base templates)
|
||||
|
||||
Usage:
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
{% if oob %}
|
||||
<div id="root-header"
|
||||
hx-swap-oob="outerHTML"
|
||||
sx-swap-oob="outerHTML"
|
||||
class="flex items-start gap-2 p-1 bg-{{ menu_colour }}-{{ (500-(level()*100))|string }}">
|
||||
{% endif %}
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{# Cart icon/badge — shows logo when empty, cart icon with count when items present #}
|
||||
|
||||
{% macro cart_icon(count=0, oob=False) %}
|
||||
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %}>
|
||||
<div id="cart-mini" {% if oob %}sx-swap-oob="{{oob}}"{% endif %}>
|
||||
{% if count == 0 %}
|
||||
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||
<a
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
id="{{id}}"
|
||||
{% if oob %}
|
||||
hx-swap-oob="true"
|
||||
sx-swap-oob="true"
|
||||
{% endif %}
|
||||
class="{{'flex justify-between items-start gap-2' if not _class else _class}}">
|
||||
{{ caller() }}
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
|
||||
{% macro menu(id, _class="") %}
|
||||
<div id="{{id}}" hx-swap-oob="outerHTML" class="{{_class}}">
|
||||
<div id="{{id}}" sx-swap-oob="outerHTML" class="{{_class}}">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<div class="relative nav-group {{_class}}">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{select}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
sx-get="{{ href }}"
|
||||
sx-target="#main-panel"
|
||||
sx-select="{{select}}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
aria-selected="{{ 'true' if (request.path|host).startswith(href) else 'false' }}"
|
||||
{% if aclass %}
|
||||
class="{{aclass}}"
|
||||
@@ -35,7 +35,7 @@
|
||||
id="{{id}}"
|
||||
{% endif %}
|
||||
{% if oob %}
|
||||
hx-swap-oob="outerHTML"
|
||||
sx-swap-oob="outerHTML"
|
||||
{% endif %}
|
||||
class="flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-{{menu_colour}}-{{(500-(level()*100))|string}}"
|
||||
>
|
||||
|
||||
@@ -14,26 +14,19 @@
|
||||
{% if has_items %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="entries-calendars-nav-wrapper"
|
||||
hx-swap-oob="true">
|
||||
sx-swap-oob="true">
|
||||
<button
|
||||
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll left"
|
||||
_="on click
|
||||
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
|
||||
onclick="document.getElementById('associated-items-container').scrollLeft -= 200">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
<div id="associated-items-container"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
style="scroll-behavior: smooth;"
|
||||
_="on load or scroll
|
||||
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||
remove .hidden from .entries-nav-arrow
|
||||
add .flex to .entries-nav-arrow
|
||||
else
|
||||
add .hidden to .entries-nav-arrow
|
||||
remove .flex from .entries-nav-arrow
|
||||
end">
|
||||
data-scroll-arrows="entries-nav-arrow"
|
||||
onscroll="(function(el){var arrows=document.getElementsByClassName('entries-nav-arrow');var show=window.innerWidth>=640&&el.scrollWidth>el.clientWidth;for(var i=0;i<arrows.length;i++){if(show){arrows[i].classList.remove('hidden');arrows[i].classList.add('flex')}else{arrows[i].classList.add('hidden');arrows[i].classList.remove('flex')}}})(this)">
|
||||
<div class="flex flex-col sm:flex-row gap-1">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
@@ -47,12 +40,11 @@
|
||||
<button
|
||||
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll right"
|
||||
_="on click
|
||||
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
|
||||
onclick="document.getElementById('associated-items-container').scrollLeft += 200">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>
|
||||
<div id="entries-calendars-nav-wrapper" sx-swap-oob="true"></div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
<button
|
||||
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll left"
|
||||
_="on click
|
||||
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft - 200">
|
||||
onclick="document.getElementById('{{ container_id }}').scrollLeft -= 200">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
@@ -28,15 +27,8 @@
|
||||
<div id="{{ container_id }}"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none {{ container_class }}"
|
||||
style="scroll-behavior: smooth;"
|
||||
_="on load or scroll
|
||||
-- Show arrows if content overflows (desktop only)
|
||||
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||
remove .hidden from .scrolling-menu-arrow-{{ container_id }}
|
||||
add .flex to .scrolling-menu-arrow-{{ container_id }}
|
||||
else
|
||||
add .hidden to .scrolling-menu-arrow-{{ container_id }}
|
||||
remove .flex from .scrolling-menu-arrow-{{ container_id }}
|
||||
end">
|
||||
data-scroll-arrows="scrolling-menu-arrow-{{ container_id }}"
|
||||
onscroll="(function(el){var cls='scrolling-menu-arrow-{{ container_id }}';var arrows=document.getElementsByClassName(cls);var show=window.innerWidth>=640&&el.scrollWidth>el.clientWidth;for(var i=0;i<arrows.length;i++){if(show){arrows[i].classList.remove('hidden');arrows[i].classList.add('flex')}else{arrows[i].classList.add('hidden');arrows[i].classList.remove('flex')}}})(this)">
|
||||
<div class="flex flex-col sm:flex-row gap-1 {{ wrapper_class }}">
|
||||
{% for item in items %}
|
||||
<div class="{{ item_class }}">
|
||||
@@ -60,8 +52,7 @@
|
||||
<button
|
||||
class="scrolling-menu-arrow-{{ container_id }} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll right"
|
||||
_="on click
|
||||
set #{{ container_id }}.scrollLeft to #{{ container_id }}.scrollLeft + 200">
|
||||
onclick="document.getElementById('{{ container_id }}').scrollLeft += 200">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
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
|
||||
sx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
sx-trigger="input changed delay:300ms"
|
||||
sx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
sx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
sx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
sx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
|
||||
sx-sync="this:replace"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
@@ -51,18 +51,18 @@
|
||||
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
|
||||
sx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
sx-trigger="input changed delay:300ms"
|
||||
sx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
sx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
sx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
|
||||
sx-swap="outerHTML"
|
||||
sx-push-url="true"
|
||||
sx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
|
||||
sx-sync="this:replace"
|
||||
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div
|
||||
id="filter"
|
||||
hx-swap-oob="outerHTML"
|
||||
sx-swap-oob="outerHTML"
|
||||
>
|
||||
{% block filter %}
|
||||
{% endblock %}
|
||||
@@ -14,14 +14,14 @@
|
||||
|
||||
<aside
|
||||
id="aside"
|
||||
hx-swap-oob="outerHTML"
|
||||
sx-swap-oob="outerHTML"
|
||||
class="hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3"
|
||||
>
|
||||
{% block aside %}
|
||||
{% endblock %}
|
||||
</aside>
|
||||
|
||||
<div id="root-menu" hx-swap-oob="outerHTML" class="md:hidden">
|
||||
<div id="root-menu" sx-swap-oob="outerHTML" class="md:hidden">
|
||||
{% block mobile_menu %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
<div class="flex-shrink-0">
|
||||
{% if list_type == "following" or a.actor_url in (followed_urls or []) %}
|
||||
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
|
||||
@@ -40,9 +40,9 @@
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('ap_social.follow') }}"
|
||||
hx-post="{{ url_for('ap_social.follow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('ap_social.follow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
|
||||
@@ -56,8 +56,8 @@
|
||||
{% endfor %}
|
||||
|
||||
{% if actors | length >= 20 %}
|
||||
<div hx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div sx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
<div class="flex-shrink-0">
|
||||
{% if a.actor_url in (followed_urls or []) %}
|
||||
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
|
||||
@@ -38,9 +38,9 @@
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('ap_social.follow') }}"
|
||||
hx-post="{{ url_for('ap_social.follow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('ap_social.follow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
|
||||
@@ -54,8 +54,8 @@
|
||||
{% endfor %}
|
||||
|
||||
{% if actors | length >= 20 %}
|
||||
<div hx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div sx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
{% if items %}
|
||||
{% set last = items[-1] %}
|
||||
{% if timeline_type == "actor" %}
|
||||
<div hx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div sx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<form method="get" action="{{ url_for('ap_social.search') }}" class="mb-6"
|
||||
hx-get="{{ url_for('ap_social.search_page') }}"
|
||||
hx-target="#search-results"
|
||||
hx-push-url="{{ url_for('ap_social.search') }}">
|
||||
sx-get="{{ url_for('ap_social.search_page') }}"
|
||||
sx-target="#search-results"
|
||||
sx-push-url="{{ url_for('ap_social.search') }}">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
|
||||
Reference in New Issue
Block a user