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:
@@ -49,14 +49,14 @@ async def federation_context() -> dict:
|
||||
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": "federation", "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
|
||||
|
||||
# Actor profile for logged-in users
|
||||
if g.get("user"):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Federation app fragment endpoints.
|
||||
|
||||
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||
Exposes sexp fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sexp.jinja_bridge import sexp
|
||||
|
||||
|
||||
def register():
|
||||
@@ -26,23 +25,22 @@ def register():
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/html")
|
||||
html = await handler()
|
||||
return Response(html, status=200, content_type="text/html")
|
||||
return Response("", status=200, content_type="text/sexp")
|
||||
src = await handler()
|
||||
return Response(src, status=200, content_type="text/sexp")
|
||||
|
||||
# --- link-card fragment: actor profile preview card --------------------------
|
||||
|
||||
def _render_federation_link_card(actor, link: str) -> str:
|
||||
return sexp(
|
||||
'(~link-card :link link :title title :image image'
|
||||
' :icon "fas fa-user" :subtitle username'
|
||||
' :detail summary :data-app "federation")',
|
||||
link=link,
|
||||
title=actor.display_name or actor.preferred_username,
|
||||
image=None,
|
||||
username=f"@{actor.preferred_username}" if actor.preferred_username else None,
|
||||
summary=actor.summary,
|
||||
)
|
||||
def _federation_link_card_sexp(actor, link: str) -> str:
|
||||
from shared.sexp.helpers import sexp_call
|
||||
return sexp_call("link-card",
|
||||
link=link,
|
||||
title=actor.display_name or actor.preferred_username,
|
||||
image=None,
|
||||
icon="fas fa-user",
|
||||
subtitle=f"@{actor.preferred_username}" if actor.preferred_username else None,
|
||||
detail=actor.summary,
|
||||
data_app="federation")
|
||||
|
||||
async def _link_card_handler():
|
||||
from quart import g
|
||||
@@ -61,7 +59,7 @@ def register():
|
||||
parts.append(f"<!-- fragment:{u} -->")
|
||||
actor = await services.federation.get_actor_by_username(g.s, u)
|
||||
if actor:
|
||||
parts.append(_render_federation_link_card(
|
||||
parts.append(_federation_link_card_sexp(
|
||||
actor, federation_url(f"/users/{actor.preferred_username}"),
|
||||
))
|
||||
return "\n".join(parts)
|
||||
@@ -73,7 +71,7 @@ def register():
|
||||
actor = await services.federation.get_actor_by_username(g.s, lookup)
|
||||
if not actor:
|
||||
return ""
|
||||
return _render_federation_link_card(
|
||||
return _federation_link_card_sexp(
|
||||
actor, federation_url(f"/users/{actor.preferred_username}"),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from quart import Blueprint, request, g, redirect, url_for, abort, Response
|
||||
|
||||
from shared.services.registry import services
|
||||
from shared.sexp.helpers import sexp_response
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,7 +59,8 @@ def register(url_prefix="/social"):
|
||||
g.s, actor.id, before=before,
|
||||
)
|
||||
from sexp.sexp_components import render_timeline_items
|
||||
return await render_timeline_items(items, "home", actor)
|
||||
sexp_src = await render_timeline_items(items, "home", actor)
|
||||
return sexp_response(sexp_src)
|
||||
|
||||
@bp.get("/public")
|
||||
async def public_timeline():
|
||||
@@ -81,7 +83,8 @@ def register(url_prefix="/social"):
|
||||
items = await services.federation.get_public_timeline(g.s, before=before)
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
from sexp.sexp_components import render_timeline_items
|
||||
return await render_timeline_items(items, "public", actor)
|
||||
sexp_src = await render_timeline_items(items, "public", actor)
|
||||
return sexp_response(sexp_src)
|
||||
|
||||
# -- Compose --------------------------------------------------------------
|
||||
|
||||
@@ -158,7 +161,8 @@ def register(url_prefix="/social"):
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
from sexp.sexp_components import render_search_results
|
||||
return await render_search_results(actors, query, page, followed_urls, actor)
|
||||
sexp_src = await render_search_results(actors, query, page, followed_urls, actor)
|
||||
return sexp_response(sexp_src)
|
||||
|
||||
@bp.post("/follow")
|
||||
async def follow():
|
||||
@@ -169,7 +173,7 @@ def register(url_prefix="/social"):
|
||||
await services.federation.send_follow(
|
||||
g.s, actor.preferred_username, remote_actor_url,
|
||||
)
|
||||
if request.headers.get("HX-Request"):
|
||||
if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
|
||||
return await _actor_card_response(actor, remote_actor_url, is_followed=True)
|
||||
return redirect(request.referrer or url_for("social.search"))
|
||||
|
||||
@@ -182,7 +186,7 @@ def register(url_prefix="/social"):
|
||||
await services.federation.unfollow(
|
||||
g.s, actor.preferred_username, remote_actor_url,
|
||||
)
|
||||
if request.headers.get("HX-Request"):
|
||||
if request.headers.get("SX-Request") or request.headers.get("HX-Request"):
|
||||
return await _actor_card_response(actor, remote_actor_url, is_followed=False)
|
||||
return redirect(request.referrer or url_for("social.search"))
|
||||
|
||||
@@ -201,7 +205,7 @@ def register(url_prefix="/social"):
|
||||
else:
|
||||
list_type = "following"
|
||||
from sexp.sexp_components import render_actor_card
|
||||
return render_actor_card(remote_dto, actor, followed_urls, list_type=list_type)
|
||||
return sexp_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
|
||||
|
||||
# -- Interactions ---------------------------------------------------------
|
||||
|
||||
@@ -290,7 +294,7 @@ def register(url_prefix="/social"):
|
||||
)).scalar())
|
||||
|
||||
from sexp.sexp_components import render_interaction_buttons
|
||||
return render_interaction_buttons(
|
||||
return sexp_response(render_interaction_buttons(
|
||||
object_id=object_id,
|
||||
author_inbox=author_inbox,
|
||||
like_count=like_count,
|
||||
@@ -298,7 +302,7 @@ def register(url_prefix="/social"):
|
||||
liked_by_me=liked_by_me,
|
||||
boosted_by_me=boosted_by_me,
|
||||
actor=actor,
|
||||
)
|
||||
))
|
||||
|
||||
# -- Following / Followers ------------------------------------------------
|
||||
|
||||
@@ -321,7 +325,8 @@ def register(url_prefix="/social"):
|
||||
g.s, actor.preferred_username, page=page,
|
||||
)
|
||||
from sexp.sexp_components import render_following_items
|
||||
return await render_following_items(actors, page, actor)
|
||||
sexp_src = await render_following_items(actors, page, actor)
|
||||
return sexp_response(sexp_src)
|
||||
|
||||
@bp.get("/followers")
|
||||
async def followers_list():
|
||||
@@ -351,7 +356,8 @@ def register(url_prefix="/social"):
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
from sexp.sexp_components import render_followers_items
|
||||
return await render_followers_items(actors, page, followed_urls, actor)
|
||||
sexp_src = await render_followers_items(actors, page, followed_urls, actor)
|
||||
return sexp_response(sexp_src)
|
||||
|
||||
@bp.get("/actor/<int:id>")
|
||||
async def actor_timeline(id: int):
|
||||
@@ -401,7 +407,8 @@ def register(url_prefix="/social"):
|
||||
g.s, id, before=before,
|
||||
)
|
||||
from sexp.sexp_components import render_actor_timeline_items
|
||||
return await render_actor_timeline_items(items, id, actor)
|
||||
sexp_src = await render_actor_timeline_items(items, id, actor)
|
||||
return sexp_response(sexp_src)
|
||||
|
||||
# -- Notifications --------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
;; Auth components (login, check email, choose username)
|
||||
|
||||
(defcomp ~federation-error-banner (&key error)
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! error)))
|
||||
(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" error))
|
||||
|
||||
(defcomp ~federation-login-form (&key error-html action csrf email)
|
||||
(defcomp ~federation-login-form (&key error action csrf email)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-6" "Sign in")
|
||||
(raw! error-html)
|
||||
error
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div
|
||||
@@ -18,21 +18,21 @@
|
||||
"Send magic link"))))
|
||||
|
||||
(defcomp ~federation-check-email-error (&key error)
|
||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! error)))
|
||||
(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" error))
|
||||
|
||||
(defcomp ~federation-check-email (&key email error-html)
|
||||
(defcomp ~federation-check-email (&key email error)
|
||||
(div :class "py-8 max-w-md mx-auto text-center"
|
||||
(h1 :class "text-2xl font-bold mb-4" "Check your email")
|
||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")
|
||||
(p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong email) ".")
|
||||
(p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")
|
||||
(raw! error-html)))
|
||||
error))
|
||||
|
||||
(defcomp ~federation-choose-username (&key domain error-html csrf username check-url)
|
||||
(defcomp ~federation-choose-username (&key domain error csrf username check-url)
|
||||
(div :class "py-8 max-w-md mx-auto"
|
||||
(h1 :class "text-2xl font-bold mb-2" "Choose your username")
|
||||
(p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "
|
||||
(strong "@username@" (raw! domain)))
|
||||
(raw! error-html)
|
||||
(strong "@username@" domain))
|
||||
error
|
||||
(form :method "post" :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(div
|
||||
@@ -43,8 +43,8 @@
|
||||
:pattern "[a-z][a-z0-9_]{2,31}" :minlength "3" :maxlength "32"
|
||||
:required true :autocomplete "off"
|
||||
:class "flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
:hx-get check-url :hx-trigger "keyup changed delay:300ms" :hx-target "#username-status"
|
||||
:hx-include "[name='username']"))
|
||||
:sx-get check-url :sx-trigger "keyup changed delay:300ms" :sx-target "#username-status"
|
||||
:sx-include "[name='username']"))
|
||||
(div :id "username-status" :class "text-sm mt-1")
|
||||
(p :class "text-xs text-stone-400 mt-1" "3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter."))
|
||||
(button :type "submit"
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
;; Notification components
|
||||
|
||||
(defcomp ~federation-notification-preview (&key preview)
|
||||
(div :class "text-sm text-stone-500 mt-1 truncate" (raw! preview)))
|
||||
(div :class "text-sm text-stone-500 mt-1 truncate" preview))
|
||||
|
||||
(defcomp ~federation-notification-card (&key cls avatar-html from-name from-username from-domain action-text preview-html time-html)
|
||||
(defcomp ~federation-notification-card (&key cls avatar from-name from-username from-domain action-text preview time)
|
||||
(div :class cls
|
||||
(div :class "flex items-start gap-3"
|
||||
(raw! avatar-html)
|
||||
avatar
|
||||
(div :class "flex-1"
|
||||
(div :class "text-sm"
|
||||
(span :class "font-semibold" (raw! from-name))
|
||||
" " (span :class "text-stone-500" "@" (raw! from-username) (raw! from-domain))
|
||||
" " (span :class "text-stone-600" (raw! action-text)))
|
||||
(raw! preview-html)
|
||||
(div :class "text-xs text-stone-400 mt-1" (raw! time-html))))))
|
||||
(span :class "font-semibold" from-name)
|
||||
" " (span :class "text-stone-500" "@" from-username from-domain)
|
||||
" " (span :class "text-stone-600" action-text))
|
||||
preview
|
||||
(div :class "text-xs text-stone-400 mt-1" time)))))
|
||||
|
||||
(defcomp ~federation-notifications-empty ()
|
||||
(p :class "text-stone-500" "No notifications yet."))
|
||||
|
||||
(defcomp ~federation-notifications-list (&key items-html)
|
||||
(div :class "space-y-2" (raw! items-html)))
|
||||
(defcomp ~federation-notifications-list (&key items)
|
||||
(div :class "space-y-2" items))
|
||||
|
||||
(defcomp ~federation-notifications-page (&key notifs-html)
|
||||
(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs-html))
|
||||
(defcomp ~federation-notifications-page (&key notifs)
|
||||
(h1 :class "text-2xl font-bold mb-6" "Notifications") notifs)
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
;; Profile and actor timeline components
|
||||
|
||||
(defcomp ~federation-actor-profile-header (&key avatar-html display-name username domain summary-html follow-html)
|
||||
(defcomp ~federation-actor-profile-header (&key avatar display-name username domain summary follow)
|
||||
(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"
|
||||
(div :class "flex items-center gap-4"
|
||||
(raw! avatar-html)
|
||||
avatar
|
||||
(div :class "flex-1"
|
||||
(h1 :class "text-xl font-bold" (raw! display-name))
|
||||
(div :class "text-stone-500" "@" (raw! username) "@" (raw! domain))
|
||||
(raw! summary-html))
|
||||
(raw! follow-html))))
|
||||
(h1 :class "text-xl font-bold" display-name)
|
||||
(div :class "text-stone-500" "@" username "@" domain)
|
||||
summary)
|
||||
follow)))
|
||||
|
||||
(defcomp ~federation-actor-timeline-layout (&key header-html timeline-html)
|
||||
(raw! header-html)
|
||||
(div :id "timeline" (raw! timeline-html)))
|
||||
(defcomp ~federation-actor-timeline-layout (&key header timeline)
|
||||
header
|
||||
(div :id "timeline" timeline))
|
||||
|
||||
(defcomp ~federation-follow-form (&key action csrf actor-url label cls)
|
||||
(div :class "flex-shrink-0"
|
||||
(form :method "post" :action action
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "actor_url" :value actor-url)
|
||||
(button :type "submit" :class cls (raw! label)))))
|
||||
(button :type "submit" :class cls label))))
|
||||
|
||||
(defcomp ~federation-profile-summary (&key summary)
|
||||
(div :class "text-sm text-stone-600 mt-2" (raw! summary)))
|
||||
(div :class "text-sm text-stone-600 mt-2" (~rich-text :html summary)))
|
||||
|
||||
;; Public profile page
|
||||
|
||||
(defcomp ~federation-activity-obj-type (&key obj-type)
|
||||
(span :class "text-sm text-stone-500" (raw! obj-type)))
|
||||
(span :class "text-sm text-stone-500" obj-type))
|
||||
|
||||
(defcomp ~federation-activity-card (&key activity-type published obj-type-html)
|
||||
(defcomp ~federation-activity-card (&key activity-type published obj-type)
|
||||
(div :class "bg-white rounded-lg shadow p-4"
|
||||
(div :class "flex justify-between items-start"
|
||||
(span :class "font-medium" (raw! activity-type))
|
||||
(span :class "text-sm text-stone-400" (raw! published)))
|
||||
(raw! obj-type-html)))
|
||||
(span :class "font-medium" activity-type)
|
||||
(span :class "text-sm text-stone-400" published))
|
||||
obj-type))
|
||||
|
||||
(defcomp ~federation-activities-list (&key items-html)
|
||||
(div :class "space-y-4" (raw! items-html)))
|
||||
(defcomp ~federation-activities-list (&key items)
|
||||
(div :class "space-y-4" items))
|
||||
|
||||
(defcomp ~federation-activities-empty ()
|
||||
(p :class "text-stone-500" "No activities yet."))
|
||||
|
||||
(defcomp ~federation-profile-page (&key display-name username domain summary-html activities-heading activities-html)
|
||||
(defcomp ~federation-profile-page (&key display-name username domain summary activities-heading activities)
|
||||
(div :class "py-8"
|
||||
(div :class "bg-white rounded-lg shadow p-6 mb-6"
|
||||
(h1 :class "text-2xl font-bold" (raw! display-name))
|
||||
(p :class "text-stone-500" "@" (raw! username) "@" (raw! domain))
|
||||
(raw! summary-html))
|
||||
(h2 :class "text-xl font-bold mb-4" (raw! activities-heading))
|
||||
(raw! activities-html)))
|
||||
(h1 :class "text-2xl font-bold" display-name)
|
||||
(p :class "text-stone-500" "@" username "@" domain)
|
||||
summary)
|
||||
(h2 :class "text-xl font-bold mb-4" activities-heading)
|
||||
activities))
|
||||
|
||||
(defcomp ~federation-profile-summary-text (&key text)
|
||||
(p :class "mt-2" (raw! text)))
|
||||
(p :class "mt-2" text))
|
||||
|
||||
@@ -4,58 +4,58 @@
|
||||
(img :src src :alt "" :class cls))
|
||||
|
||||
(defcomp ~federation-actor-avatar-placeholder (&key cls initial)
|
||||
(div :class cls (raw! initial)))
|
||||
(div :class cls initial))
|
||||
|
||||
(defcomp ~federation-actor-name-link (&key href name)
|
||||
(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name)))
|
||||
(a :href href :class "font-semibold text-stone-900 hover:underline" name))
|
||||
|
||||
(defcomp ~federation-actor-name-link-external (&key href name)
|
||||
(a :href href :target "_blank" :rel "noopener"
|
||||
:class "font-semibold text-stone-900 hover:underline" (raw! name)))
|
||||
:class "font-semibold text-stone-900 hover:underline" name))
|
||||
|
||||
(defcomp ~federation-actor-summary (&key summary)
|
||||
(div :class "text-sm text-stone-600 mt-1 truncate" (raw! summary)))
|
||||
(div :class "text-sm text-stone-600 mt-1 truncate" (~rich-text :html summary)))
|
||||
|
||||
(defcomp ~federation-unfollow-button (&key action csrf actor-url)
|
||||
(div :class "flex-shrink-0"
|
||||
(form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"
|
||||
(form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "actor_url" :value actor-url)
|
||||
(button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow"))))
|
||||
|
||||
(defcomp ~federation-follow-button (&key action csrf actor-url label)
|
||||
(div :class "flex-shrink-0"
|
||||
(form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"
|
||||
(form :method "post" :action action :sx-post action :sx-target "closest article" :sx-swap "outerHTML"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "actor_url" :value actor-url)
|
||||
(button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label)))))
|
||||
(button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" label)))))
|
||||
|
||||
(defcomp ~federation-actor-card (&key cls id avatar-html name-html username domain summary-html button-html)
|
||||
(defcomp ~federation-actor-card (&key cls id avatar name username domain summary button)
|
||||
(article :class cls :id id
|
||||
(raw! avatar-html)
|
||||
avatar
|
||||
(div :class "flex-1 min-w-0"
|
||||
(raw! name-html)
|
||||
(div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain))
|
||||
(raw! summary-html))
|
||||
(raw! button-html)))
|
||||
name
|
||||
(div :class "text-sm text-stone-500" "@" username "@" domain)
|
||||
summary)
|
||||
button))
|
||||
|
||||
(defcomp ~federation-search-info (&key cls text)
|
||||
(p :class cls (raw! text)))
|
||||
(p :class cls text))
|
||||
|
||||
(defcomp ~federation-search-page (&key search-url search-page-url query info-html results-html)
|
||||
(defcomp ~federation-search-page (&key search-url search-page-url query info results)
|
||||
(h1 :class "text-2xl font-bold mb-6" "Search")
|
||||
(form :method "get" :action search-url :class "mb-6"
|
||||
:hx-get search-page-url :hx-target "#search-results" :hx-push-url search-url
|
||||
:sx-get search-page-url :sx-target "#search-results" :sx-push-url search-url
|
||||
(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"
|
||||
:placeholder "Search users or @user@instance.tld")
|
||||
(button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search")))
|
||||
(raw! info-html)
|
||||
(div :id "search-results" (raw! results-html)))
|
||||
info
|
||||
(div :id "search-results" results))
|
||||
|
||||
;; Following / Followers list page
|
||||
(defcomp ~federation-actor-list-page (&key title count-str items-html)
|
||||
(h1 :class "text-2xl font-bold mb-6" (raw! title) " "
|
||||
(span :class "text-stone-400 font-normal" (raw! count-str)))
|
||||
(div :id "actor-list" (raw! items-html)))
|
||||
(defcomp ~federation-actor-list-page (&key title count-str items)
|
||||
(h1 :class "text-2xl font-bold mb-6" title " "
|
||||
(span :class "text-stone-400 font-normal" count-str))
|
||||
(div :id "actor-list" items))
|
||||
|
||||
@@ -10,8 +10,11 @@ import os
|
||||
from typing import Any
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import root_header_html, full_page
|
||||
from shared.sexp.jinja_bridge import load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
sexp_call, SexpExpr,
|
||||
root_header_sexp, full_page_sexp, header_child_sexp,
|
||||
)
|
||||
|
||||
# Load federation-specific .sexpr components at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
@@ -21,13 +24,13 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
# Social header nav
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _social_nav_html(actor: Any) -> str:
|
||||
def _social_nav_sexp(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 render("federation-nav-choose-username", url=choose_url)
|
||||
return sexp_call("federation-nav-choose-username", url=choose_url)
|
||||
|
||||
links = [
|
||||
("social.home_timeline", "Timeline"),
|
||||
@@ -42,7 +45,7 @@ def _social_nav_html(actor: Any) -> str:
|
||||
for endpoint, label in links:
|
||||
href = url_for(endpoint)
|
||||
bold = " font-bold" if request.path == href else ""
|
||||
parts.append(render(
|
||||
parts.append(sexp_call(
|
||||
"federation-nav-link",
|
||||
href=href,
|
||||
cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}",
|
||||
@@ -53,7 +56,7 @@ def _social_nav_html(actor: Any) -> str:
|
||||
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(render(
|
||||
parts.append(sexp_call(
|
||||
"federation-nav-notification-link",
|
||||
href=notif_url,
|
||||
cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}",
|
||||
@@ -62,36 +65,39 @@ def _social_nav_html(actor: Any) -> str:
|
||||
|
||||
# Profile link
|
||||
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
|
||||
parts.append(render(
|
||||
parts.append(sexp_call(
|
||||
"federation-nav-link",
|
||||
href=profile_url,
|
||||
cls="px-2 py-1 rounded hover:bg-stone-200",
|
||||
label=f"@{actor.preferred_username}",
|
||||
))
|
||||
|
||||
return render("federation-nav-bar", items_html="".join(parts))
|
||||
items_sexp = "(<> " + " ".join(parts) + ")"
|
||||
return sexp_call("federation-nav-bar", items=SexpExpr(items_sexp))
|
||||
|
||||
|
||||
def _social_header_html(actor: Any) -> str:
|
||||
def _social_header_sexp(actor: Any) -> str:
|
||||
"""Build the social section header row."""
|
||||
nav_html = _social_nav_html(actor)
|
||||
return render("federation-social-header", nav_html=nav_html)
|
||||
nav_sexp = _social_nav_sexp(actor)
|
||||
return sexp_call("federation-social-header", nav=SexpExpr(nav_sexp))
|
||||
|
||||
|
||||
def _social_page(ctx: dict, actor: Any, *, content_html: str,
|
||||
def _social_page(ctx: dict, actor: Any, *, content: str,
|
||||
title: str = "Rose Ash", meta_html: str = "") -> str:
|
||||
"""Render a social page with header and content."""
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("federation-header-child", inner_html=_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>')
|
||||
hdr = root_header_sexp(ctx)
|
||||
social_hdr = _social_header_sexp(actor)
|
||||
child = header_child_sexp(social_hdr)
|
||||
header_rows = "(<> " + hdr + " " + child + ")"
|
||||
return full_page_sexp(ctx, header_rows=header_rows, content=content,
|
||||
meta_html=meta_html or f'<title>{escape(title)}</title>')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _interaction_buttons_html(item: Any, actor: Any) -> str:
|
||||
def _interaction_buttons_sexp(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
|
||||
@@ -124,29 +130,31 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str:
|
||||
boost_cls = "hover:text-green-600"
|
||||
|
||||
reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
|
||||
reply_html = render("federation-reply-link", url=reply_url) if reply_url else ""
|
||||
reply_sexp = sexp_call("federation-reply-link", url=reply_url) if reply_url else ""
|
||||
|
||||
like_form = render(
|
||||
like_form = sexp_call(
|
||||
"federation-like-form",
|
||||
action=like_action, target=target, oid=oid, ainbox=ainbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {like_cls}",
|
||||
icon=like_icon, count=str(lcount),
|
||||
)
|
||||
|
||||
boost_form = render(
|
||||
boost_form = sexp_call(
|
||||
"federation-boost-form",
|
||||
action=boost_action, target=target, oid=oid, ainbox=ainbox,
|
||||
csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}",
|
||||
count=str(bcount),
|
||||
)
|
||||
|
||||
return render(
|
||||
return sexp_call(
|
||||
"federation-interaction-buttons",
|
||||
like_html=like_form, boost_html=boost_form, reply_html=reply_html,
|
||||
like=SexpExpr(like_form),
|
||||
boost=SexpExpr(boost_form),
|
||||
reply=SexpExpr(reply_sexp) if reply_sexp else None,
|
||||
)
|
||||
|
||||
|
||||
def _post_card_html(item: Any, actor: Any) -> str:
|
||||
def _post_card_sexp(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)
|
||||
@@ -159,53 +167,55 @@ def _post_card_html(item: Any, actor: Any) -> str:
|
||||
url = getattr(item, "url", None)
|
||||
post_type = getattr(item, "post_type", "")
|
||||
|
||||
boost_html = render(
|
||||
boost_sexp = sexp_call(
|
||||
"federation-boost-label", name=str(escape(boosted_by)),
|
||||
) if boosted_by else ""
|
||||
|
||||
if actor_icon:
|
||||
avatar = render("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full")
|
||||
avatar = sexp_call("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full")
|
||||
else:
|
||||
initial = actor_name[0].upper() if actor_name else "?"
|
||||
avatar = render(
|
||||
avatar = sexp_call(
|
||||
"federation-avatar-placeholder",
|
||||
cls="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
|
||||
initial=initial,
|
||||
)
|
||||
|
||||
domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
|
||||
time_html = published.strftime("%b %d, %H:%M") if published else ""
|
||||
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
|
||||
time_str = published.strftime("%b %d, %H:%M") if published else ""
|
||||
|
||||
if summary:
|
||||
content_html = render(
|
||||
content_sexp = sexp_call(
|
||||
"federation-content-cw",
|
||||
summary=str(escape(summary)), content=content,
|
||||
)
|
||||
else:
|
||||
content_html = render("federation-content-plain", content=content)
|
||||
content_sexp = sexp_call("federation-content-plain", content=content)
|
||||
|
||||
original_html = ""
|
||||
original_sexp = ""
|
||||
if url and post_type == "remote":
|
||||
original_html = render("federation-original-link", url=url)
|
||||
original_sexp = sexp_call("federation-original-link", url=url)
|
||||
|
||||
interactions_html = ""
|
||||
interactions_sexp = ""
|
||||
if actor:
|
||||
oid = getattr(item, "object_id", "") or ""
|
||||
safe_id = oid.replace("/", "_").replace(":", "_")
|
||||
interactions_html = render(
|
||||
interactions_sexp = sexp_call(
|
||||
"federation-interactions-wrap",
|
||||
id=f"interactions-{safe_id}",
|
||||
buttons_html=_interaction_buttons_html(item, actor),
|
||||
buttons=SexpExpr(_interaction_buttons_sexp(item, actor)),
|
||||
)
|
||||
|
||||
return render(
|
||||
return sexp_call(
|
||||
"federation-post-card",
|
||||
boost_html=boost_html, avatar_html=avatar,
|
||||
boost=SexpExpr(boost_sexp) if boost_sexp else None,
|
||||
avatar=SexpExpr(avatar),
|
||||
actor_name=str(escape(actor_name)),
|
||||
actor_username=str(escape(actor_username)),
|
||||
domain_html=domain_html, time_html=time_html,
|
||||
content_html=content_html, original_html=original_html,
|
||||
interactions_html=interactions_html,
|
||||
domain=domain_str, time=time_str,
|
||||
content=SexpExpr(content_sexp),
|
||||
original=SexpExpr(original_sexp) if original_sexp else None,
|
||||
interactions=SexpExpr(interactions_sexp) if interactions_sexp else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -213,12 +223,12 @@ def _post_card_html(item: Any, actor: Any) -> str:
|
||||
# Timeline items (pagination fragment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _timeline_items_html(items: list, timeline_type: str, actor: Any,
|
||||
def _timeline_items_sexp(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]
|
||||
parts = [_post_card_sexp(item, actor) for item in items]
|
||||
|
||||
if items:
|
||||
last = items[-1]
|
||||
@@ -227,16 +237,16 @@ def _timeline_items_html(items: list, timeline_type: str, actor: Any,
|
||||
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(render("federation-scroll-sentinel", url=next_url))
|
||||
parts.append(sexp_call("federation-scroll-sentinel", url=next_url))
|
||||
|
||||
return "".join(parts)
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search results (pagination fragment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _actor_card_html(a: Any, actor: Any, followed_urls: set,
|
||||
def _actor_card_sexp(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
|
||||
@@ -254,10 +264,10 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set,
|
||||
safe_id = actor_url.replace("/", "_").replace(":", "_")
|
||||
|
||||
if icon_url:
|
||||
avatar = render("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full")
|
||||
avatar = sexp_call("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full")
|
||||
else:
|
||||
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
|
||||
avatar = render(
|
||||
avatar = sexp_call(
|
||||
"federation-actor-avatar-placeholder",
|
||||
cls="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold",
|
||||
initial=initial,
|
||||
@@ -265,75 +275,77 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set,
|
||||
|
||||
# Name link
|
||||
if (list_type in ("following", "search")) and aid:
|
||||
name_html = render(
|
||||
name_sexp = sexp_call(
|
||||
"federation-actor-name-link",
|
||||
href=url_for("social.actor_timeline", id=aid),
|
||||
name=str(escape(display_name)),
|
||||
)
|
||||
else:
|
||||
name_html = render(
|
||||
name_sexp = sexp_call(
|
||||
"federation-actor-name-link-external",
|
||||
href=f"https://{domain}/@{username}",
|
||||
name=str(escape(display_name)),
|
||||
)
|
||||
|
||||
summary_html = render("federation-actor-summary", summary=summary) if summary else ""
|
||||
summary_sexp = sexp_call("federation-actor-summary", summary=summary) if summary else ""
|
||||
|
||||
# Follow/unfollow button
|
||||
button_html = ""
|
||||
button_sexp = ""
|
||||
if actor:
|
||||
is_followed = actor_url in (followed_urls or set())
|
||||
if list_type == "following" or is_followed:
|
||||
button_html = render(
|
||||
button_sexp = sexp_call(
|
||||
"federation-unfollow-button",
|
||||
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
|
||||
)
|
||||
else:
|
||||
label = "Follow Back" if list_type == "followers" else "Follow"
|
||||
button_html = render(
|
||||
button_sexp = sexp_call(
|
||||
"federation-follow-button",
|
||||
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
|
||||
)
|
||||
|
||||
return render(
|
||||
return sexp_call(
|
||||
"federation-actor-card",
|
||||
cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4",
|
||||
id=f"actor-{safe_id}",
|
||||
avatar_html=avatar, name_html=name_html,
|
||||
avatar=SexpExpr(avatar),
|
||||
name=SexpExpr(name_sexp),
|
||||
username=str(escape(username)), domain=str(escape(domain)),
|
||||
summary_html=summary_html, button_html=button_html,
|
||||
summary=SexpExpr(summary_sexp) if summary_sexp else None,
|
||||
button=SexpExpr(button_sexp) if button_sexp else None,
|
||||
)
|
||||
|
||||
|
||||
def _search_results_html(actors: list, query: str, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
def _search_results_sexp(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]
|
||||
parts = [_actor_card_sexp(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(render("federation-scroll-sentinel", url=next_url))
|
||||
return "".join(parts)
|
||||
parts.append(sexp_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
def _actor_list_items_html(actors: list, page: int, list_type: str,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
def _actor_list_items_sexp(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]
|
||||
parts = [_actor_card_sexp(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(render("federation-scroll-sentinel", url=next_url))
|
||||
return "".join(parts)
|
||||
parts.append(sexp_call("federation-scroll-sentinel", url=next_url))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _notification_html(notif: Any) -> str:
|
||||
def _notification_sexp(notif: Any) -> str:
|
||||
"""Render a single notification."""
|
||||
from_name = getattr(notif, "from_actor_name", "?")
|
||||
from_username = getattr(notif, "from_actor_username", "")
|
||||
@@ -348,16 +360,16 @@ def _notification_html(notif: Any) -> str:
|
||||
border = " border-l-4 border-l-stone-400" if not read else ""
|
||||
|
||||
if from_icon:
|
||||
avatar = render("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full")
|
||||
avatar = sexp_call("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full")
|
||||
else:
|
||||
initial = from_name[0].upper() if from_name else "?"
|
||||
avatar = render(
|
||||
avatar = sexp_call(
|
||||
"federation-avatar-placeholder",
|
||||
cls="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs",
|
||||
initial=initial,
|
||||
)
|
||||
|
||||
domain_html = f"@{escape(from_domain)}" if from_domain else ""
|
||||
domain_str = f"@{escape(from_domain)}" if from_domain else ""
|
||||
|
||||
type_map = {
|
||||
"follow": "followed you",
|
||||
@@ -370,19 +382,20 @@ def _notification_html(notif: Any) -> str:
|
||||
if ntype == "follow" and app_domain and app_domain != "federation":
|
||||
action += f" on {escape(app_domain)}"
|
||||
|
||||
preview_html = render(
|
||||
preview_sexp = sexp_call(
|
||||
"federation-notification-preview", preview=str(escape(preview)),
|
||||
) if preview else ""
|
||||
time_html = created.strftime("%b %d, %H:%M") if created else ""
|
||||
time_str = created.strftime("%b %d, %H:%M") if created else ""
|
||||
|
||||
return render(
|
||||
return sexp_call(
|
||||
"federation-notification-card",
|
||||
cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}",
|
||||
avatar_html=avatar,
|
||||
avatar=SexpExpr(avatar),
|
||||
from_name=str(escape(from_name)),
|
||||
from_username=str(escape(from_username)),
|
||||
from_domain=domain_html, action_text=action,
|
||||
preview_html=preview_html, time_html=time_html,
|
||||
from_domain=domain_str, action_text=action,
|
||||
preview=SexpExpr(preview_sexp) if preview_sexp else None,
|
||||
time=time_str,
|
||||
)
|
||||
|
||||
|
||||
@@ -392,8 +405,8 @@ def _notification_html(notif: Any) -> str:
|
||||
|
||||
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)
|
||||
hdr = root_header_sexp(ctx)
|
||||
return full_page_sexp(ctx, header_rows=hdr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -410,17 +423,17 @@ async def render_login_page(ctx: dict) -> str:
|
||||
action = url_for("auth.start_login")
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
error_html = render("federation-error-banner", error=error) if error else ""
|
||||
error_sexp = sexp_call("federation-error-banner", error=error) if error else ""
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-login-form",
|
||||
error_html=error_html, action=action, csrf=csrf,
|
||||
error=SexpExpr(error_sexp) if error_sexp else None,
|
||||
action=action, csrf=csrf,
|
||||
email=str(escape(email)),
|
||||
)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content,
|
||||
meta_html='<title>Login \u2014 Rose Ash</title>')
|
||||
return _social_page(ctx, None, content=content,
|
||||
title="Login \u2014 Rose Ash")
|
||||
|
||||
|
||||
async def render_check_email_page(ctx: dict) -> str:
|
||||
@@ -428,18 +441,18 @@ async def render_check_email_page(ctx: dict) -> str:
|
||||
email = ctx.get("email", "")
|
||||
email_error = ctx.get("email_error")
|
||||
|
||||
error_html = render(
|
||||
error_sexp = sexp_call(
|
||||
"federation-check-email-error", error=str(escape(email_error)),
|
||||
) if email_error else ""
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-check-email",
|
||||
email=str(escape(email)), error_html=error_html,
|
||||
email=str(escape(email)),
|
||||
error=SexpExpr(error_sexp) if error_sexp else None,
|
||||
)
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content,
|
||||
meta_html='<title>Check your email \u2014 Rose Ash</title>')
|
||||
return _social_page(ctx, None, content=content,
|
||||
title="Check your email \u2014 Rose Ash")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -452,26 +465,28 @@ async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
|
||||
from quart import url_for
|
||||
|
||||
label = "Home" if timeline_type == "home" else "Public"
|
||||
compose_html = ""
|
||||
compose_sexp = ""
|
||||
if actor:
|
||||
compose_url = url_for("social.compose_form")
|
||||
compose_html = render("federation-compose-button", url=compose_url)
|
||||
compose_sexp = sexp_call("federation-compose-button", url=compose_url)
|
||||
|
||||
timeline_html = _timeline_items_html(items, timeline_type, actor)
|
||||
timeline_sexp = _timeline_items_sexp(items, timeline_type, actor)
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-timeline-page",
|
||||
label=label, compose_html=compose_html, timeline_html=timeline_html,
|
||||
label=label,
|
||||
compose=SexpExpr(compose_sexp) if compose_sexp else None,
|
||||
timeline=SexpExpr(timeline_sexp) if timeline_sexp else None,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=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)
|
||||
return _timeline_items_sexp(items, timeline_type, actor, actor_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -486,19 +501,20 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st
|
||||
csrf = generate_csrf_token()
|
||||
action = url_for("social.compose_submit")
|
||||
|
||||
reply_html = ""
|
||||
reply_sexp = ""
|
||||
if reply_to:
|
||||
reply_html = render(
|
||||
reply_sexp = sexp_call(
|
||||
"federation-compose-reply",
|
||||
reply_to=str(escape(reply_to)),
|
||||
)
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-compose-form",
|
||||
action=action, csrf=csrf, reply_html=reply_html,
|
||||
action=action, csrf=csrf,
|
||||
reply=SexpExpr(reply_sexp) if reply_sexp else None,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title="Compose \u2014 Rose Ash")
|
||||
|
||||
|
||||
@@ -514,38 +530,39 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
|
||||
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)
|
||||
results_sexp = _search_results_sexp(actors, query, page, followed_urls, actor)
|
||||
|
||||
info_html = ""
|
||||
info_sexp = ""
|
||||
if query and total:
|
||||
s = "s" if total != 1 else ""
|
||||
info_html = render(
|
||||
info_sexp = sexp_call(
|
||||
"federation-search-info",
|
||||
cls="text-sm text-stone-500 mb-4",
|
||||
text=f"{total} result{s} for <strong>{escape(query)}</strong>",
|
||||
)
|
||||
elif query:
|
||||
info_html = render(
|
||||
info_sexp = sexp_call(
|
||||
"federation-search-info",
|
||||
cls="text-stone-500 mb-4",
|
||||
text=f"No results found for <strong>{escape(query)}</strong>",
|
||||
)
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-search-page",
|
||||
search_url=search_url, search_page_url=search_page_url,
|
||||
query=str(escape(query)),
|
||||
info_html=info_html, results_html=results_html,
|
||||
info=SexpExpr(info_sexp) if info_sexp else None,
|
||||
results=SexpExpr(results_sexp) if results_sexp else None,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=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)
|
||||
return _search_results_sexp(actors, query, page, followed_urls, actor)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -555,36 +572,38 @@ async def render_search_results(actors: list, query: str, page: int,
|
||||
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 = render(
|
||||
items_sexp = _actor_list_items_sexp(actors, 1, "following", set(), actor)
|
||||
content = sexp_call(
|
||||
"federation-actor-list-page",
|
||||
title="Following", count_str=f"({total})", items_html=items_html,
|
||||
title="Following", count_str=f"({total})",
|
||||
items=SexpExpr(items_sexp) if items_sexp else None,
|
||||
)
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=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)
|
||||
return _actor_list_items_sexp(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 = render(
|
||||
items_sexp = _actor_list_items_sexp(actors, 1, "followers", followed_urls, actor)
|
||||
content = sexp_call(
|
||||
"federation-actor-list-page",
|
||||
title="Followers", count_str=f"({total})", items_html=items_html,
|
||||
title="Followers", count_str=f"({total})",
|
||||
items=SexpExpr(items_sexp) if items_sexp else None,
|
||||
)
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=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)
|
||||
return _actor_list_items_sexp(actors, page, "followers", followed_urls, actor)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -604,58 +623,60 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
|
||||
actor_url = getattr(remote_actor, "actor_url", "")
|
||||
|
||||
if icon_url:
|
||||
avatar = render("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full")
|
||||
avatar = sexp_call("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full")
|
||||
else:
|
||||
initial = display_name[0].upper() if display_name else "?"
|
||||
avatar = render(
|
||||
avatar = sexp_call(
|
||||
"federation-avatar-placeholder",
|
||||
cls="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl",
|
||||
initial=initial,
|
||||
)
|
||||
|
||||
summary_html = render("federation-profile-summary", summary=summary) if summary else ""
|
||||
summary_sexp = sexp_call("federation-profile-summary", summary=summary) if summary else ""
|
||||
|
||||
follow_html = ""
|
||||
follow_sexp = ""
|
||||
if actor:
|
||||
if is_following:
|
||||
follow_html = render(
|
||||
follow_sexp = sexp_call(
|
||||
"federation-follow-form",
|
||||
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
|
||||
label="Unfollow",
|
||||
cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100",
|
||||
)
|
||||
else:
|
||||
follow_html = render(
|
||||
follow_sexp = sexp_call(
|
||||
"federation-follow-form",
|
||||
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url,
|
||||
label="Follow",
|
||||
cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700",
|
||||
)
|
||||
|
||||
timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
|
||||
timeline_sexp = _timeline_items_sexp(items, "actor", actor, remote_actor.id)
|
||||
|
||||
header_html = render(
|
||||
header_sexp = sexp_call(
|
||||
"federation-actor-profile-header",
|
||||
avatar_html=avatar,
|
||||
avatar=SexpExpr(avatar),
|
||||
display_name=str(escape(display_name)),
|
||||
username=str(escape(remote_actor.preferred_username)),
|
||||
domain=str(escape(remote_actor.domain)),
|
||||
summary_html=summary_html, follow_html=follow_html,
|
||||
summary=SexpExpr(summary_sexp) if summary_sexp else None,
|
||||
follow=SexpExpr(follow_sexp) if follow_sexp else None,
|
||||
)
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-actor-timeline-layout",
|
||||
header_html=header_html, timeline_html=timeline_html,
|
||||
header=SexpExpr(header_sexp),
|
||||
timeline=SexpExpr(timeline_sexp) if timeline_sexp else None,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=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)
|
||||
return _timeline_items_sexp(items, "actor", actor, actor_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -666,15 +687,16 @@ async def render_notifications_page(ctx: dict, notifications: list,
|
||||
actor: Any) -> str:
|
||||
"""Full page: notifications."""
|
||||
if not notifications:
|
||||
notif_html = render("federation-notifications-empty")
|
||||
notif_sexp = sexp_call("federation-notifications-empty")
|
||||
else:
|
||||
notif_html = render(
|
||||
items_sexp = "(<> " + " ".join(_notification_sexp(n) for n in notifications) + ")"
|
||||
notif_sexp = sexp_call(
|
||||
"federation-notifications-list",
|
||||
items_html="".join(_notification_html(n) for n in notifications),
|
||||
items=SexpExpr(items_sexp),
|
||||
)
|
||||
|
||||
content = render("federation-notifications-page", notifs_html=notif_html)
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
content = sexp_call("federation-notifications-page", notifs=SexpExpr(notif_sexp))
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title="Notifications \u2014 Rose Ash")
|
||||
|
||||
|
||||
@@ -695,16 +717,17 @@ async def render_choose_username_page(ctx: dict) -> str:
|
||||
check_url = url_for("identity.check_username")
|
||||
actor = ctx.get("actor")
|
||||
|
||||
error_html = render("federation-error-banner", error=error) if error else ""
|
||||
error_sexp = sexp_call("federation-error-banner", error=error) if error else ""
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-choose-username",
|
||||
domain=str(escape(ap_domain)), error_html=error_html,
|
||||
domain=str(escape(ap_domain)),
|
||||
error=SexpExpr(error_sexp) if error_sexp else None,
|
||||
csrf=csrf, username=str(escape(username)),
|
||||
check_url=check_url,
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title="Choose Username \u2014 Rose Ash")
|
||||
|
||||
|
||||
@@ -719,38 +742,39 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
|
||||
|
||||
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||
display_name = actor.display_name or actor.preferred_username
|
||||
summary_html = render(
|
||||
summary_sexp = sexp_call(
|
||||
"federation-profile-summary-text", text=str(escape(actor.summary)),
|
||||
) if actor.summary else ""
|
||||
|
||||
activities_html = ""
|
||||
activities_sexp = ""
|
||||
if activities:
|
||||
parts = []
|
||||
for a in activities:
|
||||
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
|
||||
obj_type_html = render(
|
||||
obj_type_sexp = sexp_call(
|
||||
"federation-activity-obj-type", obj_type=a.object_type,
|
||||
) if a.object_type else ""
|
||||
parts.append(render(
|
||||
parts.append(sexp_call(
|
||||
"federation-activity-card",
|
||||
activity_type=a.activity_type, published=published,
|
||||
obj_type_html=obj_type_html,
|
||||
obj_type=SexpExpr(obj_type_sexp) if obj_type_sexp else None,
|
||||
))
|
||||
activities_html = render("federation-activities-list", items_html="".join(parts))
|
||||
items_sexp = "(<> " + " ".join(parts) + ")"
|
||||
activities_sexp = sexp_call("federation-activities-list", items=SexpExpr(items_sexp))
|
||||
else:
|
||||
activities_html = render("federation-activities-empty")
|
||||
activities_sexp = sexp_call("federation-activities-empty")
|
||||
|
||||
content = render(
|
||||
content = sexp_call(
|
||||
"federation-profile-page",
|
||||
display_name=str(escape(display_name)),
|
||||
username=str(escape(actor.preferred_username)),
|
||||
domain=str(escape(ap_domain)),
|
||||
summary_html=summary_html,
|
||||
summary=SexpExpr(summary_sexp) if summary_sexp else None,
|
||||
activities_heading=f"Activities ({total})",
|
||||
activities_html=activities_html,
|
||||
activities=SexpExpr(activities_sexp),
|
||||
)
|
||||
|
||||
return _social_page(ctx, actor, content_html=content,
|
||||
return _social_page(ctx, actor, content=content,
|
||||
title=f"@{actor.preferred_username} \u2014 Rose Ash")
|
||||
|
||||
|
||||
@@ -772,10 +796,10 @@ def render_interaction_buttons(object_id: str, author_inbox: str,
|
||||
liked_by_me=liked_by_me,
|
||||
boosted_by_me=boosted_by_me,
|
||||
)
|
||||
return _interaction_buttons_html(item, actor)
|
||||
return _interaction_buttons_sexp(item, actor)
|
||||
|
||||
|
||||
def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set,
|
||||
*, list_type: str = "following") -> str:
|
||||
"""Render a single actor card fragment for HTMX POST response."""
|
||||
return _actor_card_html(actor_dto, actor, followed_urls, list_type=list_type)
|
||||
return _actor_card_sexp(actor_dto, actor, followed_urls, list_type=list_type)
|
||||
|
||||
@@ -7,60 +7,60 @@
|
||||
(a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username")))
|
||||
|
||||
(defcomp ~federation-nav-link (&key href cls label)
|
||||
(a :href href :class cls (raw! label)))
|
||||
(a :href href :class cls label))
|
||||
|
||||
(defcomp ~federation-nav-notification-link (&key href cls count-url)
|
||||
(a :href href :class cls "Notifications"
|
||||
(span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML"
|
||||
(span :sx-get count-url :sx-trigger "load, every 30s" :sx-swap "innerHTML"
|
||||
:class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden")))
|
||||
|
||||
(defcomp ~federation-nav-bar (&key items-html)
|
||||
(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items-html)))
|
||||
(defcomp ~federation-nav-bar (&key items)
|
||||
(nav :class "flex gap-3 text-sm items-center flex-wrap" items))
|
||||
|
||||
(defcomp ~federation-social-header (&key nav-html)
|
||||
(defcomp ~federation-social-header (&key nav)
|
||||
(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! nav-html))))
|
||||
(div :class "w-full flex flex-row items-center gap-2 flex-wrap" nav)))
|
||||
|
||||
(defcomp ~federation-header-child (&key inner-html)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! inner-html)))
|
||||
(defcomp ~federation-header-child (&key inner)
|
||||
(div :id "root-header-child" :class "flex flex-col w-full items-center" inner))
|
||||
|
||||
;; --- Post card ---
|
||||
|
||||
(defcomp ~federation-boost-label (&key name)
|
||||
(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name)))
|
||||
(div :class "text-sm text-stone-500 mb-2" "Boosted by " name))
|
||||
|
||||
(defcomp ~federation-avatar-img (&key src cls)
|
||||
(img :src src :alt "" :class cls))
|
||||
|
||||
(defcomp ~federation-avatar-placeholder (&key cls initial)
|
||||
(div :class cls (raw! initial)))
|
||||
(div :class cls initial))
|
||||
|
||||
(defcomp ~federation-content-cw (&key summary content)
|
||||
(details :class "mt-2"
|
||||
(summary :class "text-stone-500 cursor-pointer" "CW: " (raw! summary))
|
||||
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content))))
|
||||
(summary :class "text-stone-500 cursor-pointer" "CW: " (~rich-text :html summary))
|
||||
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content))))
|
||||
|
||||
(defcomp ~federation-content-plain (&key content)
|
||||
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content)))
|
||||
(div :class "mt-2 prose prose-sm prose-stone max-w-none" (~rich-text :html content)))
|
||||
|
||||
(defcomp ~federation-original-link (&key url)
|
||||
(a :href url :target "_blank" :rel "noopener"
|
||||
:class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original"))
|
||||
|
||||
(defcomp ~federation-interactions-wrap (&key id buttons-html)
|
||||
(div :id id (raw! buttons-html)))
|
||||
(defcomp ~federation-interactions-wrap (&key id buttons)
|
||||
(div :id id buttons))
|
||||
|
||||
(defcomp ~federation-post-card (&key boost-html avatar-html actor-name actor-username domain-html time-html content-html original-html interactions-html)
|
||||
(defcomp ~federation-post-card (&key boost avatar actor-name actor-username domain time content original interactions)
|
||||
(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"
|
||||
(raw! boost-html)
|
||||
boost
|
||||
(div :class "flex items-start gap-3"
|
||||
(raw! avatar-html)
|
||||
avatar
|
||||
(div :class "flex-1 min-w-0"
|
||||
(div :class "flex items-baseline gap-2"
|
||||
(span :class "font-semibold text-stone-900" (raw! actor-name))
|
||||
(span :class "text-sm text-stone-500" "@" (raw! actor-username) (raw! domain-html))
|
||||
(span :class "text-sm text-stone-400 ml-auto" (raw! time-html)))
|
||||
(raw! content-html) (raw! original-html) (raw! interactions-html)))))
|
||||
(span :class "font-semibold text-stone-900" actor-name)
|
||||
(span :class "text-sm text-stone-500" "@" actor-username domain)
|
||||
(span :class "text-sm text-stone-400 ml-auto" time))
|
||||
content original interactions))))
|
||||
|
||||
;; --- Interaction buttons ---
|
||||
|
||||
@@ -68,48 +68,48 @@
|
||||
(a :href url :class "hover:text-stone-700" "Reply"))
|
||||
|
||||
(defcomp ~federation-like-form (&key action target oid ainbox csrf cls icon count)
|
||||
(form :hx-post action :hx-target target :hx-swap "innerHTML"
|
||||
(form :sx-post action :sx-target target :sx-swap "innerHTML"
|
||||
(input :type "hidden" :name "object_id" :value oid)
|
||||
(input :type "hidden" :name "author_inbox" :value ainbox)
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class cls (span (raw! icon)) " " (raw! count))))
|
||||
(button :type "submit" :class cls (span icon) " " count)))
|
||||
|
||||
(defcomp ~federation-boost-form (&key action target oid ainbox csrf cls count)
|
||||
(form :hx-post action :hx-target target :hx-swap "innerHTML"
|
||||
(form :sx-post action :sx-target target :sx-swap "innerHTML"
|
||||
(input :type "hidden" :name "object_id" :value oid)
|
||||
(input :type "hidden" :name "author_inbox" :value ainbox)
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit" :class cls (span "\u21bb") " " (raw! count))))
|
||||
(button :type "submit" :class cls (span "\u21bb") " " count)))
|
||||
|
||||
(defcomp ~federation-interaction-buttons (&key like-html boost-html reply-html)
|
||||
(defcomp ~federation-interaction-buttons (&key like boost reply)
|
||||
(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"
|
||||
(raw! like-html) (raw! boost-html) (raw! reply-html)))
|
||||
like boost reply))
|
||||
|
||||
;; --- Timeline ---
|
||||
|
||||
(defcomp ~federation-scroll-sentinel (&key url)
|
||||
(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML"))
|
||||
(div :sx-get url :sx-trigger "revealed" :sx-swap "outerHTML"))
|
||||
|
||||
(defcomp ~federation-compose-button (&key url)
|
||||
(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose"))
|
||||
|
||||
(defcomp ~federation-timeline-page (&key label compose-html timeline-html)
|
||||
(defcomp ~federation-timeline-page (&key label compose timeline)
|
||||
(div :class "flex items-center justify-between mb-6"
|
||||
(h1 :class "text-2xl font-bold" (raw! label) " Timeline")
|
||||
(raw! compose-html))
|
||||
(div :id "timeline" (raw! timeline-html)))
|
||||
(h1 :class "text-2xl font-bold" label " Timeline")
|
||||
compose)
|
||||
(div :id "timeline" timeline))
|
||||
|
||||
;; --- Compose ---
|
||||
|
||||
(defcomp ~federation-compose-reply (&key reply-to)
|
||||
(input :type "hidden" :name "in_reply_to" :value reply-to)
|
||||
(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! reply-to))))
|
||||
(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" reply-to)))
|
||||
|
||||
(defcomp ~federation-compose-form (&key action csrf reply-html)
|
||||
(defcomp ~federation-compose-form (&key action csrf reply)
|
||||
(h1 :class "text-2xl font-bold mb-6" "Compose")
|
||||
(form :method "post" :action action :class "space-y-4"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(raw! reply-html)
|
||||
reply
|
||||
(textarea :name "content" :rows "6" :maxlength "5000" :required true
|
||||
:class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
:placeholder "What's on your mind?")
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<a href="{{ url_for('social.notifications') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 relative {% if request.path == url_for('social.notifications') %}font-bold{% endif %}">
|
||||
Notifications
|
||||
<span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML"
|
||||
<span sx-get="{{ url_for('social.notification_count') }}" sx-trigger="load, every 30s" sx-swap="innerHTML"
|
||||
class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span>
|
||||
</a>
|
||||
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
|
||||
|
||||
@@ -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('social.unfollow') }}"
|
||||
hx-post="{{ url_for('social.unfollow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('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('social.follow') }}"
|
||||
hx-post="{{ url_for('social.follow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('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('social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div sx-get="{{ url_for('social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">
|
||||
{% if liked %}
|
||||
<form hx-post="{{ url_for('social.unlike') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<form sx-post="{{ url_for('social.unlike') }}"
|
||||
sx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
sx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
@@ -18,9 +18,9 @@
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{{ url_for('social.like') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<form sx-post="{{ url_for('social.like') }}"
|
||||
sx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
sx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
@@ -31,9 +31,9 @@
|
||||
{% endif %}
|
||||
|
||||
{% if boosted %}
|
||||
<form hx-post="{{ url_for('social.unboost') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<form sx-post="{{ url_for('social.unboost') }}"
|
||||
sx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
sx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
@@ -42,9 +42,9 @@
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{{ url_for('social.boost') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<form sx-post="{{ url_for('social.boost') }}"
|
||||
sx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
sx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
<div class="flex-shrink-0">
|
||||
{% if a.actor_url in (followed_urls or []) %}
|
||||
<form method="post" action="{{ url_for('social.unfollow') }}"
|
||||
hx-post="{{ url_for('social.unfollow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('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('social.follow') }}"
|
||||
hx-post="{{ url_for('social.follow') }}"
|
||||
hx-target="closest article"
|
||||
hx-swap="outerHTML">
|
||||
sx-post="{{ url_for('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('social.search_page', q=query, page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div sx-get="{{ url_for('social.search_page', q=query, page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
{% if items %}
|
||||
{% set last = items[-1] %}
|
||||
{% if timeline_type == "actor" %}
|
||||
<div hx-get="{{ url_for('social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div sx-get="{{ url_for('social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% else %}
|
||||
<div hx-get="{{ url_for('social.' ~ timeline_type ~ '_timeline_page', before=last.published.isoformat()) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
<div sx-get="{{ url_for('social.' ~ timeline_type ~ '_timeline_page', before=last.published.isoformat()) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
required
|
||||
autocomplete="off"
|
||||
class="flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
hx-get="{{ url_for('identity.check_username') }}"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#username-status"
|
||||
hx-include="[name='username']"
|
||||
sx-get="{{ url_for('identity.check_username') }}"
|
||||
sx-trigger="keyup changed delay:300ms"
|
||||
sx-target="#username-status"
|
||||
sx-include="[name='username']"
|
||||
>
|
||||
</div>
|
||||
<div id="username-status" class="text-sm mt-1"></div>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<form method="get" action="{{ url_for('social.search') }}" class="mb-6"
|
||||
hx-get="{{ url_for('social.search_page') }}"
|
||||
hx-target="#search-results"
|
||||
hx-push-url="{{ url_for('social.search') }}">
|
||||
sx-get="{{ url_for('social.search_page') }}"
|
||||
sx-target="#search-results"
|
||||
sx-push-url="{{ url_for('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