Phase 6: Replace render_template() with s-expression rendering in all GET routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s

Migrate ~52 GET route handlers across all 7 services from Jinja
render_template() to s-expression component rendering. Each service
gets a sexp_components.py with page/oob/cards render functions.

- Add per-service sexp_components.py (account, blog, cart, events,
  federation, market, orders) with full page, OOB, and pagination
  card rendering
- Add shared/sexp/helpers.py with call_url, root_header_html,
  full_page, oob_page utilities
- Update all GET routes to use get_template_context() + render fns
- Fix get_template_context() to inject Jinja globals (URL helpers)
- Add qs_filter to base_context for sexp filter URL building
- Mount sexp_components.py in docker-compose.dev.yml for all services
- Import sexp_components in app.py for Hypercorn --reload watching
- Fix route_prefix import (shared.utils not shared.infrastructure.urls)
- Fix federation choose-username missing actor in context
- Fix market page_markets missing post in context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 23:19:33 +00:00
parent 8013317b41
commit d53b9648a9
53 changed files with 8690 additions and 463 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request
@@ -93,8 +94,13 @@ def create_app() -> "Quart":
# --- home page ---
@app.get("/")
async def home():
from quart import render_template
return await render_template("_types/federation/index.html")
from quart import make_response
from shared.sexp.page import get_template_context
from sexp_components import render_federation_home
ctx = await get_template_context()
html = await render_federation_home(ctx)
return await make_response(html)
return app

View File

@@ -100,7 +100,10 @@ def register(url_prefix="/auth"):
# If there's a pending redirect (e.g. OAuth authorize), follow it
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
return await render_template("auth/login.html")
from shared.sexp.page import get_template_context
from sexp_components import render_login_page
ctx = await get_template_context()
return await render_login_page(ctx)
@auth_bp.post("/start/")
async def start_login():

View File

@@ -39,7 +39,11 @@ def register(url_prefix="/identity"):
if actor:
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username))
return await render_template("federation/choose_username.html")
from shared.sexp.page import get_template_context
from sexp_components import render_choose_username_page
ctx = await get_template_context()
ctx["actor"] = actor
return await render_choose_username_page(ctx)
@bp.post("/choose-username")
async def choose_username():

View File

@@ -39,12 +39,10 @@ def register(url_prefix="/social"):
return redirect(url_for("auth.login_form"))
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
return await render_template(
"federation/timeline.html",
items=items,
timeline_type="home",
actor=actor,
)
from shared.sexp.page import get_template_context
from sexp_components import render_timeline_page
ctx = await get_template_context()
return await render_timeline_page(ctx, items, "home", actor)
@bp.get("/timeline")
async def home_timeline_page():
@@ -59,23 +57,17 @@ def register(url_prefix="/social"):
items = await services.federation.get_home_timeline(
g.s, actor.id, before=before,
)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="home",
actor=actor,
)
from sexp_components import render_timeline_items
return await render_timeline_items(items, "home", actor)
@bp.get("/public")
async def public_timeline():
items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None)
return await render_template(
"federation/timeline.html",
items=items,
timeline_type="public",
actor=actor,
)
from shared.sexp.page import get_template_context
from sexp_components import render_timeline_page
ctx = await get_template_context()
return await render_timeline_page(ctx, items, "public", actor)
@bp.get("/public/timeline")
async def public_timeline_page():
@@ -88,12 +80,8 @@ def register(url_prefix="/social"):
pass
items = await services.federation.get_public_timeline(g.s, before=before)
actor = getattr(g, "_social_actor", None)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="public",
actor=actor,
)
from sexp_components import render_timeline_items
return await render_timeline_items(items, "public", actor)
# -- Compose --------------------------------------------------------------
@@ -101,11 +89,10 @@ def register(url_prefix="/social"):
async def compose_form():
actor = _require_actor()
reply_to = request.args.get("reply_to")
return await render_template(
"federation/compose.html",
actor=actor,
reply_to=reply_to,
)
from shared.sexp.page import get_template_context
from sexp_components import render_compose_page
ctx = await get_template_context()
return await render_compose_page(ctx, actor, reply_to)
@bp.post("/compose")
async def compose_submit():
@@ -148,15 +135,10 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/search.html",
query=query,
actors=actors,
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
from shared.sexp.page import get_template_context
from sexp_components import render_search_page
ctx = await get_template_context()
return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor)
@bp.get("/search/page")
async def search_page():
@@ -175,15 +157,8 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/_search_results.html",
actors=actors,
total=total,
page=page,
query=query,
followed_urls=followed_urls,
actor=actor,
)
from sexp_components import render_search_results
return await render_search_results(actors, query, page, followed_urls, actor)
@bp.post("/follow")
async def follow():
@@ -340,13 +315,10 @@ def register(url_prefix="/social"):
actors, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
return await render_template(
"federation/following.html",
actors=actors,
total=total,
page=1,
actor=actor,
)
from shared.sexp.page import get_template_context
from sexp_components import render_following_page
ctx = await get_template_context()
return await render_following_page(ctx, actors, total, actor)
@bp.get("/following/page")
async def following_list_page():
@@ -355,15 +327,8 @@ def register(url_prefix="/social"):
actors, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page,
)
return await render_template(
"federation/_actor_list_items.html",
actors=actors,
total=total,
page=page,
list_type="following",
followed_urls=set(),
actor=actor,
)
from sexp_components import render_following_items
return await render_following_items(actors, page, actor)
@bp.get("/followers")
async def followers_list():
@@ -376,14 +341,10 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/followers.html",
actors=actors,
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
from shared.sexp.page import get_template_context
from sexp_components import render_followers_page
ctx = await get_template_context()
return await render_followers_page(ctx, actors, total, followed_urls, actor)
@bp.get("/followers/page")
async def followers_list_page():
@@ -396,15 +357,8 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
return await render_template(
"federation/_actor_list_items.html",
actors=actors,
total=total,
page=page,
list_type="followers",
followed_urls=followed_urls,
actor=actor,
)
from sexp_components import render_followers_items
return await render_followers_items(actors, page, followed_urls, actor)
@bp.get("/actor/<int:id>")
async def actor_timeline(id: int):
@@ -435,13 +389,10 @@ def register(url_prefix="/social"):
)
).scalar_one_or_none()
is_following = existing is not None
return await render_template(
"federation/actor_timeline.html",
remote_actor=remote_dto,
items=items,
is_following=is_following,
actor=actor,
)
from shared.sexp.page import get_template_context
from sexp_components import render_actor_timeline_page
ctx = await get_template_context()
return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor)
@bp.get("/actor/<int:id>/timeline")
async def actor_timeline_page(id: int):
@@ -456,13 +407,8 @@ def register(url_prefix="/social"):
items = await services.federation.get_actor_timeline(
g.s, id, before=before,
)
return await render_template(
"federation/_timeline_items.html",
items=items,
timeline_type="actor",
actor_id=id,
actor=actor,
)
from sexp_components import render_actor_timeline_items
return await render_actor_timeline_items(items, id, actor)
# -- Notifications --------------------------------------------------------
@@ -471,11 +417,10 @@ def register(url_prefix="/social"):
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
return await render_template(
"federation/notifications.html",
notifications=items,
actor=actor,
)
from shared.sexp.page import get_template_context
from sexp_components import render_notifications_page
ctx = await get_template_context()
return await render_notifications_page(ctx, items, actor)
@bp.get("/notifications/count")
async def notification_count():

View File

@@ -0,0 +1,710 @@
"""
Federation service s-expression page components.
Renders social timeline, compose, search, following/followers, notifications,
actor profiles, login, and username selection pages.
"""
from __future__ import annotations
from typing import Any
from markupsafe import escape
from shared.sexp.jinja_bridge import sexp
from shared.sexp.helpers import root_header_html, full_page
# ---------------------------------------------------------------------------
# Social header nav
# ---------------------------------------------------------------------------
def _social_nav_html(actor: Any) -> str:
"""Build the social header nav bar content."""
from quart import url_for, request
if not actor:
choose_url = url_for("identity.choose_username_form")
return (
'<nav class="flex gap-3 text-sm items-center">'
f'<a href="{choose_url}" class="px-2 py-1 rounded hover:bg-stone-200 font-bold">Choose username</a>'
'</nav>'
)
links = [
("social.home_timeline", "Timeline"),
("social.public_timeline", "Public"),
("social.compose_form", "Compose"),
("social.following_list", "Following"),
("social.followers_list", "Followers"),
("social.search", "Search"),
]
parts = ['<nav class="flex gap-3 text-sm items-center flex-wrap">']
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
parts.append(f'<a href="{href}" class="px-2 py-1 rounded hover:bg-stone-200{bold}">{label}</a>')
# Notifications with live badge
notif_url = url_for("social.notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(
f'<a href="{notif_url}" class="px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}">Notifications'
f'<span hx-get="{notif_count_url}" hx-trigger="load, every 30s" hx-swap="innerHTML"'
f' class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span></a>'
)
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(f'<a href="{profile_url}" class="px-2 py-1 rounded hover:bg-stone-200">@{actor.preferred_username}</a>')
parts.append('</nav>')
return "".join(parts)
def _social_header_html(actor: Any) -> str:
"""Build the social section header row."""
nav_html = _social_nav_html(actor)
return sexp(
'(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"'
' (div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nh)))',
nh=nav_html,
)
def _social_page(ctx: dict, actor: Any, *, content_html: str,
title: str = "Rose Ash", meta_html: str = "") -> str:
"""Render a social page with header and content."""
hdr = root_header_html(ctx)
hdr += sexp(
'(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! sh))',
sh=_social_header_html(actor),
)
return full_page(ctx, header_rows_html=hdr, content_html=content_html,
meta_html=meta_html or f'<title>{escape(title)}</title>')
# ---------------------------------------------------------------------------
# Post card
# ---------------------------------------------------------------------------
def _interaction_buttons_html(item: Any, actor: Any) -> str:
"""Render like/boost/reply buttons for a post."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
oid = getattr(item, "object_id", "") or ""
ainbox = getattr(item, "author_inbox", "") or ""
lcount = getattr(item, "like_count", 0) or 0
bcount = getattr(item, "boost_count", 0) or 0
liked = getattr(item, "liked_by_me", False)
boosted = getattr(item, "boosted_by_me", False)
csrf = generate_csrf_token()
safe_id = oid.replace("/", "_").replace(":", "_")
target = f"#interactions-{safe_id}"
if liked:
like_action = url_for("social.unlike")
like_cls = "text-red-500 hover:text-red-600"
like_icon = "&#x2665;"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
like_icon = "&#x2661;"
if boosted:
boost_action = url_for("social.unboost")
boost_cls = "text-green-600 hover:text-green-700"
else:
boost_action = url_for("social.boost")
boost_cls = "hover:text-green-600"
reply_url = url_for("social.compose_form", reply_to=oid) if oid else ""
reply_html = f'<a href="{reply_url}" class="hover:text-stone-700">Reply</a>' if reply_url else ""
return (
f'<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">'
f'<form hx-post="{like_action}" hx-target="{target}" hx-swap="innerHTML">'
f'<input type="hidden" name="object_id" value="{oid}">'
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="flex items-center gap-1 {like_cls}"><span>{like_icon}</span> {lcount}</button></form>'
f'<form hx-post="{boost_action}" hx-target="{target}" hx-swap="innerHTML">'
f'<input type="hidden" name="object_id" value="{oid}">'
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="flex items-center gap-1 {boost_cls}"><span>&#x21BB;</span> {bcount}</button></form>'
f'{reply_html}</div>'
)
def _post_card_html(item: Any, actor: Any) -> str:
"""Render a single timeline post card."""
boosted_by = getattr(item, "boosted_by", None)
actor_icon = getattr(item, "actor_icon", None)
actor_name = getattr(item, "actor_name", "?")
actor_username = getattr(item, "actor_username", "")
actor_domain = getattr(item, "actor_domain", "")
content = getattr(item, "content", "")
summary = getattr(item, "summary", None)
published = getattr(item, "published", None)
url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "")
boost_html = f'<div class="text-sm text-stone-500 mb-2">Boosted by {escape(boosted_by)}</div>' if boosted_by else ""
if actor_icon:
avatar = f'<img src="{actor_icon}" alt="" class="w-10 h-10 rounded-full">'
else:
initial = actor_name[0].upper() if actor_name else "?"
avatar = f'<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">{initial}</div>'
domain_html = f"@{escape(actor_domain)}" if actor_domain else ""
time_html = published.strftime("%b %d, %H:%M") if published else ""
if summary:
content_html = (
f'<details class="mt-2"><summary class="text-stone-500 cursor-pointer">CW: {escape(summary)}</summary>'
f'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div></details>'
)
else:
content_html = f'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div>'
original_html = ""
if url and post_type == "remote":
original_html = f'<a href="{url}" target="_blank" rel="noopener" class="text-sm text-stone-400 hover:underline mt-1 inline-block">original</a>'
interactions_html = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_html = f'<div id="interactions-{safe_id}">{_interaction_buttons_html(item, actor)}</div>'
return (
f'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">'
f'{boost_html}'
f'<div class="flex items-start gap-3">{avatar}'
f'<div class="flex-1 min-w-0">'
f'<div class="flex items-baseline gap-2">'
f'<span class="font-semibold text-stone-900">{escape(actor_name)}</span>'
f'<span class="text-sm text-stone-500">@{escape(actor_username)}{domain_html}</span>'
f'<span class="text-sm text-stone-400 ml-auto">{time_html}</span></div>'
f'{content_html}{original_html}{interactions_html}</div></div></article>'
)
# ---------------------------------------------------------------------------
# Timeline items (pagination fragment)
# ---------------------------------------------------------------------------
def _timeline_items_html(items: list, timeline_type: str, actor: Any,
actor_id: int | None = None) -> str:
"""Render timeline items with infinite scroll sentinel."""
from quart import url_for
parts = [_post_card_html(item, actor) for item in items]
if items:
last = items[-1]
before = last.published.isoformat() if last.published else ""
if timeline_type == "actor" and actor_id is not None:
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
parts.append(f'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Search results (pagination fragment)
# ---------------------------------------------------------------------------
def _actor_card_html(a: Any, actor: Any, followed_urls: set,
*, list_type: str = "search") -> str:
"""Render a single actor card with follow/unfollow button."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
username = getattr(a, "preferred_username", "")
domain = getattr(a, "domain", "")
icon_url = getattr(a, "icon_url", None)
actor_url = getattr(a, "actor_url", "")
summary = getattr(a, "summary", None)
aid = getattr(a, "id", None)
safe_id = actor_url.replace("/", "_").replace(":", "_")
if icon_url:
avatar = f'<img src="{icon_url}" alt="" class="w-12 h-12 rounded-full">'
else:
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
avatar = f'<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">{initial}</div>'
# Name link
if list_type == "following" and aid:
name_html = f'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
elif list_type == "search" and aid:
name_html = f'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
else:
name_html = f'<a href="https://{domain}/@{username}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
summary_html = f'<div class="text-sm text-stone-600 mt-1 truncate">{summary}</div>' if summary else ""
# Follow/unfollow button
button_html = ""
if actor:
is_followed = actor_url in (followed_urls or set())
if list_type == "following" or is_followed:
button_html = (
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}"'
f' hx-post="{url_for("social.unfollow")}" hx-target="closest article" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">Unfollow</button></form></div>'
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
button_html = (
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}"'
f' hx-post="{url_for("social.follow")}" hx-target="closest article" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">{label}</button></form></div>'
)
return (
f'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" id="actor-{safe_id}">'
f'{avatar}<div class="flex-1 min-w-0">{name_html}'
f'<div class="text-sm text-stone-500">@{escape(username)}@{escape(domain)}</div>'
f'{summary_html}</div>{button_html}</article>'
)
def _search_results_html(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Render search results with pagination sentinel."""
from quart import url_for
parts = [_actor_card_html(a, actor, followed_urls, list_type="search") for a in actors]
if len(actors) >= 20:
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(f'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
return "".join(parts)
def _actor_list_items_html(actors: list, page: int, list_type: str,
followed_urls: set, actor: Any) -> str:
"""Render actor list items (following/followers) with pagination sentinel."""
from quart import url_for
parts = [_actor_card_html(a, actor, followed_urls, list_type=list_type) for a in actors]
if len(actors) >= 20:
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
parts.append(f'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# Notification card
# ---------------------------------------------------------------------------
def _notification_html(notif: Any) -> str:
"""Render a single notification."""
from_name = getattr(notif, "from_actor_name", "?")
from_username = getattr(notif, "from_actor_username", "")
from_domain = getattr(notif, "from_actor_domain", "")
from_icon = getattr(notif, "from_actor_icon", None)
ntype = getattr(notif, "notification_type", "")
preview = getattr(notif, "target_content_preview", None)
created = getattr(notif, "created_at", None)
read = getattr(notif, "read", True)
app_domain = getattr(notif, "app_domain", "")
border = " border-l-4 border-l-stone-400" if not read else ""
if from_icon:
avatar = f'<img src="{from_icon}" alt="" class="w-8 h-8 rounded-full">'
else:
initial = from_name[0].upper() if from_name else "?"
avatar = f'<div class="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs">{initial}</div>'
domain_html = f"@{escape(from_domain)}" if from_domain else ""
type_map = {
"follow": "followed you",
"like": "liked your post",
"boost": "boosted your post",
"mention": "mentioned you",
"reply": "replied to your post",
}
action = type_map.get(ntype, "")
if ntype == "follow" and app_domain and app_domain != "federation":
action += f" on {escape(app_domain)}"
preview_html = f'<div class="text-sm text-stone-500 mt-1 truncate">{escape(preview)}</div>' if preview else ""
time_html = created.strftime("%b %d, %H:%M") if created else ""
return (
f'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}">'
f'<div class="flex items-start gap-3">{avatar}<div class="flex-1">'
f'<div class="text-sm"><span class="font-semibold">{escape(from_name)}</span>'
f' <span class="text-stone-500">@{escape(from_username)}{domain_html}</span>'
f' <span class="text-stone-600">{action}</span></div>'
f'{preview_html}<div class="text-xs text-stone-400 mt-1">{time_html}</div></div></div></div>'
)
# ---------------------------------------------------------------------------
# Public API: Home page
# ---------------------------------------------------------------------------
async def render_federation_home(ctx: dict) -> str:
"""Full page: federation home (minimal)."""
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr)
# ---------------------------------------------------------------------------
# Public API: Login
# ---------------------------------------------------------------------------
async def render_login_page(ctx: dict) -> str:
"""Full page: federation login form."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
error = ctx.get("error", "")
email = ctx.get("email", "")
action = url_for("auth.start_login")
csrf = generate_csrf_token()
error_html = f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' if error else ""
content = (
f'<div class="py-8 max-w-md mx-auto"><h1 class="text-2xl font-bold mb-6">Sign in</h1>{error_html}'
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
f'<input type="email" name="email" id="email" value="{escape(email)}" required autofocus'
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Send magic link</button></form></div>'
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content,
meta_html="<title>Login \u2014 Rose Ash</title>")
# ---------------------------------------------------------------------------
# Public API: Timeline
# ---------------------------------------------------------------------------
async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
actor: Any) -> str:
"""Full page: timeline (home or public)."""
from quart import url_for
label = "Home" if timeline_type == "home" else "Public"
compose_html = ""
if actor:
compose_url = url_for("social.compose_form")
compose_html = f'<a href="{compose_url}" class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">Compose</a>'
timeline_html = _timeline_items_html(items, timeline_type, actor)
content = (
f'<div class="flex items-center justify-between mb-6">'
f'<h1 class="text-2xl font-bold">{label} Timeline</h1>{compose_html}</div>'
f'<div id="timeline">{timeline_html}</div>'
)
return _social_page(ctx, actor, content_html=content,
title=f"{label} Timeline \u2014 Rose Ash")
async def render_timeline_items(items: list, timeline_type: str,
actor: Any, actor_id: int | None = None) -> str:
"""Pagination fragment: timeline items."""
return _timeline_items_html(items, timeline_type, actor, actor_id)
# ---------------------------------------------------------------------------
# Public API: Compose
# ---------------------------------------------------------------------------
async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> str:
"""Full page: compose form."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
action = url_for("social.compose_submit")
reply_html = ""
if reply_to:
reply_html = (
f'<input type="hidden" name="in_reply_to" value="{escape(reply_to)}">'
f'<div class="text-sm text-stone-500">Replying to <span class="font-mono">{escape(reply_to)}</span></div>'
)
content = (
f'<h1 class="text-2xl font-bold mb-6">Compose</h1>'
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">{reply_html}'
f'<textarea name="content" rows="6" maxlength="5000" required'
f' class="w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' placeholder="What\'s on your mind?"></textarea>'
f'<div class="flex items-center justify-between">'
f'<select name="visibility" class="border border-stone-300 rounded px-3 py-1.5 text-sm">'
f'<option value="public">Public</option><option value="unlisted">Unlisted</option>'
f'<option value="followers">Followers only</option></select>'
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Publish</button></div></form>'
)
return _social_page(ctx, actor, content_html=content,
title="Compose \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: Search
# ---------------------------------------------------------------------------
async def render_search_page(ctx: dict, query: str, actors: list, total: int,
page: int, followed_urls: set, actor: Any) -> str:
"""Full page: search."""
from quart import url_for
search_url = url_for("social.search")
search_page_url = url_for("social.search_page")
results_html = _search_results_html(actors, query, page, followed_urls, actor)
info_html = ""
if query and total:
s = "s" if total != 1 else ""
info_html = f'<p class="text-sm text-stone-500 mb-4">{total} result{s} for <strong>{escape(query)}</strong></p>'
elif query:
info_html = f'<p class="text-stone-500 mb-4">No results found for <strong>{escape(query)}</strong></p>'
content = (
f'<h1 class="text-2xl font-bold mb-6">Search</h1>'
f'<form method="get" action="{search_url}" class="mb-6"'
f' hx-get="{search_page_url}" hx-target="#search-results" hx-push-url="{search_url}">'
f'<div class="flex gap-2"><input type="text" name="q" value="{escape(query)}"'
f' class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' placeholder="Search users or @user@instance.tld">'
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Search</button></div></form>'
f'{info_html}<div id="search-results">{results_html}</div>'
)
return _social_page(ctx, actor, content_html=content,
title="Search \u2014 Rose Ash")
async def render_search_results(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: search results."""
return _search_results_html(actors, query, page, followed_urls, actor)
# ---------------------------------------------------------------------------
# Public API: Following / Followers
# ---------------------------------------------------------------------------
async def render_following_page(ctx: dict, actors: list, total: int,
actor: Any) -> str:
"""Full page: following list."""
items_html = _actor_list_items_html(actors, 1, "following", set(), actor)
content = (
f'<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({total})</span></h1>'
f'<div id="actor-list">{items_html}</div>'
)
return _social_page(ctx, actor, content_html=content,
title="Following \u2014 Rose Ash")
async def render_following_items(actors: list, page: int, actor: Any) -> str:
"""Pagination fragment: following items."""
return _actor_list_items_html(actors, page, "following", set(), actor)
async def render_followers_page(ctx: dict, actors: list, total: int,
followed_urls: set, actor: Any) -> str:
"""Full page: followers list."""
items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor)
content = (
f'<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({total})</span></h1>'
f'<div id="actor-list">{items_html}</div>'
)
return _social_page(ctx, actor, content_html=content,
title="Followers \u2014 Rose Ash")
async def render_followers_items(actors: list, page: int,
followed_urls: set, actor: Any) -> str:
"""Pagination fragment: followers items."""
return _actor_list_items_html(actors, page, "followers", followed_urls, actor)
# ---------------------------------------------------------------------------
# Public API: Actor timeline
# ---------------------------------------------------------------------------
async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
is_following: bool, actor: Any) -> str:
"""Full page: remote actor timeline."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
csrf = generate_csrf_token()
display_name = remote_actor.display_name or remote_actor.preferred_username
icon_url = getattr(remote_actor, "icon_url", None)
summary = getattr(remote_actor, "summary", None)
actor_url = getattr(remote_actor, "actor_url", "")
if icon_url:
avatar = f'<img src="{icon_url}" alt="" class="w-16 h-16 rounded-full">'
else:
initial = display_name[0].upper() if display_name else "?"
avatar = f'<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">{initial}</div>'
summary_html = f'<div class="text-sm text-stone-600 mt-2">{summary}</div>' if summary else ""
follow_html = ""
if actor:
if is_following:
follow_html = (
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">Unfollow</button></form></div>'
)
else:
follow_html = (
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">Follow</button></form></div>'
)
timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
content = (
f'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">'
f'<div class="flex items-center gap-4">{avatar}'
f'<div class="flex-1"><h1 class="text-xl font-bold">{escape(display_name)}</h1>'
f'<div class="text-stone-500">@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}</div>'
f'{summary_html}</div>{follow_html}</div></div>'
f'<div id="timeline">{timeline_html}</div>'
)
return _social_page(ctx, actor, content_html=content,
title=f"{display_name} \u2014 Rose Ash")
async def render_actor_timeline_items(items: list, actor_id: int,
actor: Any) -> str:
"""Pagination fragment: actor timeline items."""
return _timeline_items_html(items, "actor", actor, actor_id)
# ---------------------------------------------------------------------------
# Public API: Notifications
# ---------------------------------------------------------------------------
async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str:
"""Full page: notifications."""
if not notifications:
notif_html = '<p class="text-stone-500">No notifications yet.</p>'
else:
notif_html = '<div class="space-y-2">' + "".join(_notification_html(n) for n in notifications) + '</div>'
content = f'<h1 class="text-2xl font-bold mb-6">Notifications</h1>{notif_html}'
return _social_page(ctx, actor, content_html=content,
title="Notifications \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: Choose username
# ---------------------------------------------------------------------------
async def render_choose_username_page(ctx: dict) -> str:
"""Full page: choose username form."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
from shared.config import config
csrf = generate_csrf_token()
error = ctx.get("error", "")
username = ctx.get("username", "")
ap_domain = config().get("ap_domain", "rose-ash.com")
check_url = url_for("identity.check_username")
actor = ctx.get("actor")
error_html = f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' if error else ""
content = (
f'<div class="py-8 max-w-md mx-auto">'
f'<h1 class="text-2xl font-bold mb-2">Choose your username</h1>'
f'<p class="text-stone-600 mb-6">This will be your identity on the fediverse: '
f'<strong>@username@{escape(ap_domain)}</strong></p>'
f'{error_html}'
f'<form method="post" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<div><label for="username" class="block text-sm font-medium mb-1">Username</label>'
f'<div class="flex items-center"><span class="text-stone-400 mr-1">@</span>'
f'<input type="text" name="username" id="username" value="{escape(username)}"'
f' pattern="[a-z][a-z0-9_]{{2,31}}" minlength="3" maxlength="32" required autocomplete="off"'
f' class="flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' hx-get="{check_url}" hx-trigger="keyup changed delay:300ms" hx-target="#username-status" hx-include="[name=\'username\']">'
f'</div><div id="username-status" class="text-sm mt-1"></div>'
f'<p class="text-xs text-stone-400 mt-1">3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.</p></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">Claim username</button></form></div>'
)
return _social_page(ctx, actor, content_html=content,
title="Choose Username \u2014 Rose Ash")
# ---------------------------------------------------------------------------
# Public API: Actor profile
# ---------------------------------------------------------------------------
async def render_profile_page(ctx: dict, actor: Any, activities: list,
total: int) -> str:
"""Full page: actor profile."""
from shared.config import config
ap_domain = config().get("ap_domain", "rose-ash.com")
display_name = actor.display_name or actor.preferred_username
summary_html = f'<p class="mt-2">{escape(actor.summary)}</p>' if actor.summary else ""
activities_html = ""
if activities:
parts = []
for a in activities:
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else ""
obj_type = f'<span class="text-sm text-stone-500">{a.object_type}</span>' if a.object_type else ""
parts.append(
f'<div class="bg-white rounded-lg shadow p-4"><div class="flex justify-between items-start">'
f'<span class="font-medium">{a.activity_type}</span>'
f'<span class="text-sm text-stone-400">{published}</span></div>{obj_type}</div>'
)
activities_html = '<div class="space-y-4">' + "".join(parts) + '</div>'
else:
activities_html = '<p class="text-stone-500">No activities yet.</p>'
content = (
f'<div class="py-8"><div class="bg-white rounded-lg shadow p-6 mb-6">'
f'<h1 class="text-2xl font-bold">{escape(display_name)}</h1>'
f'<p class="text-stone-500">@{escape(actor.preferred_username)}@{escape(ap_domain)}</p>'
f'{summary_html}</div>'
f'<h2 class="text-xl font-bold mb-4">Activities ({total})</h2>{activities_html}</div>'
)
return _social_page(ctx, actor, content_html=content,
title=f"@{actor.preferred_username} \u2014 Rose Ash")