Migrate all apps to defpage declarative page routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m41s

Replace Python GET page handlers with declarative defpage definitions in .sx
files across all 8 apps (sx docs, orders, account, market, cart, federation,
events, blog). Each app now has sxc/pages/ with setup functions, layout
registrations, page helpers, and .sx defpage declarations.

Core infrastructure: add g I/O primitive, PageDef support for auth/layout/
data/content/filter/aside/menu slots, post_author auth level, and custom
layout registration. Remove ~1400 lines of render_*_page/render_*_oob
boilerplate. Update all endpoint references in routes, sx_components, and
templates to defpage_* naming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 14:52:34 +00:00
parent 5b4cacaf19
commit c243d17eeb
108 changed files with 3598 additions and 2851 deletions

View File

@@ -84,11 +84,20 @@ def create_app() -> "Quart":
app.jinja_loader,
])
# --- defpage setup ---
from sxc.pages import setup_federation_pages
setup_federation_pages()
# --- blueprints ---
# Well-known + actors (webfinger, inbox, outbox, etc.) are now handled
# by the shared AP blueprint registered in create_base_app().
app.register_blueprint(register_identity_bp())
app.register_blueprint(register_social_bp())
social_bp = register_social_bp()
from shared.sx.pages import mount_pages
mount_pages(social_bp, "federation")
app.register_blueprint(social_bp)
app.register_blueprint(register_fragments())
# --- home page ---

View File

@@ -32,18 +32,103 @@ def register(url_prefix="/social"):
actor = await services.federation.get_actor_by_user_id(g.s, g.user.id)
g._social_actor = actor
# -- Timeline -------------------------------------------------------------
@bp.before_request
async def _prepare_page_data():
"""Pre-render content for defpage routes."""
endpoint = request.endpoint or ""
@bp.get("/")
async def home_timeline():
if not g.get("user"):
return redirect(url_for("auth.login_form"))
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from shared.sx.page import get_template_context
from sx.sx_components import render_timeline_page
ctx = await get_template_context()
return await render_timeline_page(ctx, items, "home", actor)
if endpoint.endswith("defpage_home_timeline"):
actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id)
from sx.sx_components import _timeline_content_sx
g.home_timeline_content = _timeline_content_sx(items, "home", actor)
elif endpoint.endswith("defpage_public_timeline"):
actor = getattr(g, "_social_actor", None)
items = await services.federation.get_public_timeline(g.s)
from sx.sx_components import _timeline_content_sx
g.public_timeline_content = _timeline_content_sx(items, "public", actor)
elif endpoint.endswith("defpage_compose_form"):
actor = _require_actor()
from sx.sx_components import _compose_content_sx
reply_to = request.args.get("reply_to")
g.compose_content = _compose_content_sx(actor, reply_to)
elif endpoint.endswith("defpage_search"):
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors_list, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _search_content_sx
g.search_content = _search_content_sx(query, actors_list, total, 1, followed_urls, actor)
elif endpoint.endswith("defpage_following_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from sx.sx_components import _following_content_sx
g.following_content = _following_content_sx(actors_list, total, actor)
elif endpoint.endswith("defpage_followers_list"):
actor = _require_actor()
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import _followers_content_sx
g.followers_content = _followers_content_sx(actors_list, total, followed_urls, actor)
elif endpoint.endswith("defpage_actor_timeline"):
actor = getattr(g, "_social_actor", None)
actor_id = request.view_args.get("id")
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == actor_id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, actor_id)
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == actor_id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from sx.sx_components import _actor_timeline_content_sx
g.actor_timeline_content = _actor_timeline_content_sx(remote_dto, items, is_following, actor)
elif endpoint.endswith("defpage_notifications"):
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from sx.sx_components import _notifications_content_sx
g.notifications_content = _notifications_content_sx(items)
# -- Timeline pagination ---------------------------------------------------
@bp.get("/timeline")
async def home_timeline_page():
@@ -62,15 +147,6 @@ def register(url_prefix="/social"):
sx_src = await render_timeline_items(items, "home", actor)
return sx_response(sx_src)
@bp.get("/public")
async def public_timeline():
items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None)
from shared.sx.page import get_template_context
from sx.sx_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():
before_str = request.args.get("before")
@@ -86,16 +162,7 @@ def register(url_prefix="/social"):
sx_src = await render_timeline_items(items, "public", actor)
return sx_response(sx_src)
# -- Compose --------------------------------------------------------------
@bp.get("/compose")
async def compose_form():
actor = _require_actor()
reply_to = request.args.get("reply_to")
from shared.sx.page import get_template_context
from sx.sx_components import render_compose_page
ctx = await get_template_context()
return await render_compose_page(ctx, actor, reply_to)
# -- Compose ---------------------------------------------------------------
@bp.post("/compose")
async def compose_submit():
@@ -103,7 +170,7 @@ def register(url_prefix="/social"):
form = await request.form
content = form.get("content", "").strip()
if not content:
return redirect(url_for("social.compose_form"))
return redirect(url_for("social.defpage_compose_form"))
visibility = form.get("visibility", "public")
in_reply_to = form.get("in_reply_to") or None
@@ -114,45 +181,26 @@ def register(url_prefix="/social"):
visibility=visibility,
in_reply_to=in_reply_to,
)
return redirect(url_for("social.home_timeline"))
return redirect(url_for("social.defpage_home_timeline"))
@bp.post("/delete/<int:post_id>")
async def delete_post(post_id: int):
actor = _require_actor()
await services.federation.delete_local_post(g.s, actor.id, post_id)
return redirect(url_for("social.home_timeline"))
return redirect(url_for("social.defpage_home_timeline"))
# -- Search + Follow ------------------------------------------------------
@bp.get("/search")
async def search():
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
actors = []
total = 0
followed_urls: set[str] = set()
if query:
actors, total = await services.federation.search_actors(g.s, query)
if actor:
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from shared.sx.page import get_template_context
from sx.sx_components import render_search_page
ctx = await get_template_context()
return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor)
# -- Search + Follow -------------------------------------------------------
@bp.get("/search/page")
async def search_page():
actor = getattr(g, "_social_actor", None)
query = request.args.get("q", "").strip()
page = request.args.get("page", 1, type=int)
actors = []
actors_list = []
total = 0
followed_urls: set[str] = set()
if query:
actors, total = await services.federation.search_actors(
actors_list, total = await services.federation.search_actors(
g.s, query, page=page,
)
if actor:
@@ -161,7 +209,7 @@ def register(url_prefix="/social"):
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import render_search_results
sx_src = await render_search_results(actors, query, page, followed_urls, actor)
sx_src = await render_search_results(actors_list, query, page, followed_urls, actor)
return sx_response(sx_src)
@bp.post("/follow")
@@ -175,7 +223,7 @@ def register(url_prefix="/social"):
)
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"))
return redirect(request.referrer or url_for("social.defpage_search"))
@bp.post("/unfollow")
async def unfollow():
@@ -188,7 +236,7 @@ def register(url_prefix="/social"):
)
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"))
return redirect(request.referrer or url_for("social.defpage_search"))
async def _actor_card_response(actor, remote_actor_url, is_followed):
"""Re-render a single actor card after follow/unfollow via HTMX."""
@@ -198,7 +246,6 @@ def register(url_prefix="/social"):
if not remote_dto:
return Response("", status=200)
followed_urls = {remote_actor_url} if is_followed else set()
# Detect list context from referer
referer = request.referrer or ""
if "/followers" in referer:
list_type = "followers"
@@ -207,7 +254,7 @@ def register(url_prefix="/social"):
from sx.sx_components import render_actor_card
return sx_response(render_actor_card(remote_dto, actor, followed_urls, list_type=list_type))
# -- Interactions ---------------------------------------------------------
# -- Interactions ----------------------------------------------------------
@bp.post("/like")
async def like():
@@ -216,7 +263,6 @@ def register(url_prefix="/social"):
object_id = form.get("object_id", "")
author_inbox = form.get("author_inbox", "")
await services.federation.like_post(g.s, actor.id, object_id, author_inbox)
# Return updated buttons for HTMX
return await _interaction_buttons_response(actor, object_id, author_inbox)
@bp.post("/unlike")
@@ -250,7 +296,6 @@ def register(url_prefix="/social"):
"""Re-render interaction buttons after a like/boost action."""
from shared.models.federation import APInteraction, APRemotePost, APActivity
from sqlalchemy import select
from shared.services.federation_impl import SqlFederationService
svc = services.federation
post_type, post_id = await svc._resolve_post(g.s, object_id)
@@ -304,51 +349,24 @@ def register(url_prefix="/social"):
actor=actor,
))
# -- Following / Followers ------------------------------------------------
@bp.get("/following")
async def following_list():
actor = _require_actor()
actors, total = await services.federation.get_following(
g.s, actor.preferred_username,
)
from shared.sx.page import get_template_context
from sx.sx_components import render_following_page
ctx = await get_template_context()
return await render_following_page(ctx, actors, total, actor)
# -- Following / Followers pagination --------------------------------------
@bp.get("/following/page")
async def following_list_page():
actor = _require_actor()
page = request.args.get("page", 1, type=int)
actors, total = await services.federation.get_following(
actors_list, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page,
)
from sx.sx_components import render_following_items
sx_src = await render_following_items(actors, page, actor)
sx_src = await render_following_items(actors_list, page, actor)
return sx_response(sx_src)
@bp.get("/followers")
async def followers_list():
actor = _require_actor()
actors, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username,
)
# Build set of followed actor URLs to show Follow Back vs Unfollow
following, _ = await services.federation.get_following(
g.s, actor.preferred_username, page=1, per_page=1000,
)
followed_urls = {a.actor_url for a in following}
from shared.sx.page import get_template_context
from sx.sx_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():
actor = _require_actor()
page = request.args.get("page", 1, type=int)
actors, total = await services.federation.get_followers_paginated(
actors_list, total = await services.federation.get_followers_paginated(
g.s, actor.preferred_username, page=page,
)
following, _ = await services.federation.get_following(
@@ -356,43 +374,9 @@ def register(url_prefix="/social"):
)
followed_urls = {a.actor_url for a in following}
from sx.sx_components import render_followers_items
sx_src = await render_followers_items(actors, page, followed_urls, actor)
sx_src = await render_followers_items(actors_list, page, followed_urls, actor)
return sx_response(sx_src)
@bp.get("/actor/<int:id>")
async def actor_timeline(id: int):
actor = getattr(g, "_social_actor", None)
# Get remote actor info
from shared.models.federation import RemoteActor
from sqlalchemy import select as sa_select
remote = (
await g.s.execute(
sa_select(RemoteActor).where(RemoteActor.id == id)
)
).scalar_one_or_none()
if not remote:
abort(404)
from shared.services.federation_impl import _remote_actor_to_dto
remote_dto = _remote_actor_to_dto(remote)
items = await services.federation.get_actor_timeline(g.s, id)
# Check if we follow this actor
is_following = False
if actor:
from shared.models.federation import APFollowing
existing = (
await g.s.execute(
sa_select(APFollowing).where(
APFollowing.actor_profile_id == actor.id,
APFollowing.remote_actor_id == id,
)
)
).scalar_one_or_none()
is_following = existing is not None
from shared.sx.page import get_template_context
from sx.sx_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):
actor = getattr(g, "_social_actor", None)
@@ -410,17 +394,7 @@ def register(url_prefix="/social"):
sx_src = await render_actor_timeline_items(items, id, actor)
return sx_response(sx_src)
# -- Notifications --------------------------------------------------------
@bp.get("/notifications")
async def notifications():
actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id)
from shared.sx.page import get_template_context
from sx.sx_components import render_notifications_page
ctx = await get_template_context()
return await render_notifications_page(ctx, items, actor)
# -- Notifications ---------------------------------------------------------
@bp.get("/notifications/count")
async def notification_count():
@@ -440,6 +414,6 @@ def register(url_prefix="/social"):
async def mark_read():
actor = _require_actor()
await services.federation.mark_notifications_read(g.s, actor.id)
return redirect(url_for("social.notifications"))
return redirect(url_for("social.defpage_notifications"))
return bp

View File

@@ -35,12 +35,12 @@ def _social_nav_sx(actor: Any) -> str:
return sx_call("federation-nav-choose-username", url=choose_url)
links = [
("social.home_timeline", "Timeline"),
("social.public_timeline", "Public"),
("social.compose_form", "Compose"),
("social.following_list", "Following"),
("social.followers_list", "Followers"),
("social.search", "Search"),
("social.defpage_home_timeline", "Timeline"),
("social.defpage_public_timeline", "Public"),
("social.defpage_compose_form", "Compose"),
("social.defpage_following_list", "Following"),
("social.defpage_followers_list", "Followers"),
("social.defpage_search", "Search"),
]
parts = []
@@ -51,7 +51,7 @@ def _social_nav_sx(actor: Any) -> str:
parts.append(f'(a :href {serialize(href)} :class {serialize(cls)} {serialize(label)})')
# Notifications with live badge
notif_url = url_for("social.notifications")
notif_url = url_for("social.defpage_notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(sx_call(
@@ -122,7 +122,7 @@ def _interaction_buttons_sx(item: Any, actor: Any) -> str:
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_url = url_for("social.defpage_compose_form", reply_to=oid) if oid else ""
reply_sx = sx_call("federation-reply-link", url=reply_url) if reply_url else ""
like_form = sx_call(
@@ -260,7 +260,7 @@ def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
if (list_type in ("following", "search")) and aid:
name_sx = sx_call(
"federation-actor-name-link",
href=url_for("social.actor_timeline", id=aid),
href=url_for("social.defpage_actor_timeline", id=aid),
name=str(escape(display_name)),
)
else:
@@ -436,32 +436,28 @@ async def render_check_email_page(ctx: dict) -> str:
# ---------------------------------------------------------------------------
# Public API: Timeline
# Content builders (used by defpage before_request)
# ---------------------------------------------------------------------------
async def render_timeline_page(ctx: dict, items: list, timeline_type: str,
actor: Any) -> str:
"""Full page: timeline (home or public)."""
def _timeline_content_sx(items: list, timeline_type: str, actor: Any) -> str:
"""Build timeline content SX string."""
from quart import url_for
label = "Home" if timeline_type == "home" else "Public"
compose_sx = ""
if actor:
compose_url = url_for("social.compose_form")
compose_url = url_for("social.defpage_compose_form")
compose_sx = sx_call("federation-compose-button", url=compose_url)
timeline_sx = _timeline_items_sx(items, timeline_type, actor)
content = sx_call(
return sx_call(
"federation-timeline-page",
label=label,
compose=SxExpr(compose_sx) if compose_sx else None,
timeline=SxExpr(timeline_sx) if timeline_sx else None,
)
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:
@@ -469,12 +465,8 @@ async def render_timeline_items(items: list, timeline_type: str,
return _timeline_items_sx(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."""
def _compose_content_sx(actor: Any, reply_to: str | None) -> str:
"""Build compose form content SX string."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
@@ -488,26 +480,19 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st
reply_to=str(escape(reply_to)),
)
content = sx_call(
return sx_call(
"federation-compose-form",
action=action, csrf=csrf,
reply=SxExpr(reply_sx) if reply_sx else None,
)
return _social_page(ctx, actor, content=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."""
def _search_content_sx(query: str, actors: list, total: int,
page: int, followed_urls: set, actor: Any) -> str:
"""Build search page content SX string."""
from quart import url_for
search_url = url_for("social.search")
search_url = url_for("social.defpage_search")
search_page_url = url_for("social.search_page")
results_sx = _search_results_sx(actors, query, page, followed_urls, actor)
@@ -527,7 +512,7 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
text=f"No results found for <strong>{escape(query)}</strong>",
)
content = sx_call(
return sx_call(
"federation-search-page",
search_url=search_url, search_page_url=search_page_url,
query=str(escape(query)),
@@ -535,9 +520,6 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int,
results=SxExpr(results_sx) if results_sx else None,
)
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:
@@ -545,21 +527,14 @@ async def render_search_results(actors: list, query: str, page: int,
return _search_results_sx(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."""
def _following_content_sx(actors: list, total: int, actor: Any) -> str:
"""Build following list content SX string."""
items_sx = _actor_list_items_sx(actors, 1, "following", set(), actor)
content = sx_call(
return sx_call(
"federation-actor-list-page",
title="Following", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None,
)
return _social_page(ctx, actor, content=content,
title="Following \u2014 Rose Ash")
async def render_following_items(actors: list, page: int, actor: Any) -> str:
@@ -567,17 +542,15 @@ async def render_following_items(actors: list, page: int, actor: Any) -> str:
return _actor_list_items_sx(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."""
def _followers_content_sx(actors: list, total: int,
followed_urls: set, actor: Any) -> str:
"""Build followers list content SX string."""
items_sx = _actor_list_items_sx(actors, 1, "followers", followed_urls, actor)
content = sx_call(
return sx_call(
"federation-actor-list-page",
title="Followers", count_str=f"({total})",
items=SxExpr(items_sx) if items_sx else None,
)
return _social_page(ctx, actor, content=content,
title="Followers \u2014 Rose Ash")
async def render_followers_items(actors: list, page: int,
@@ -586,13 +559,9 @@ async def render_followers_items(actors: list, page: int,
return _actor_list_items_sx(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."""
def _actor_timeline_content_sx(remote_actor: Any, items: list,
is_following: bool, actor: Any) -> str:
"""Build actor timeline content SX string."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for
@@ -640,15 +609,12 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list,
follow=SxExpr(follow_sx) if follow_sx else None,
)
content = sx_call(
return sx_call(
"federation-actor-timeline-layout",
header=SxExpr(header_sx),
timeline=SxExpr(timeline_sx) if timeline_sx else None,
)
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:
@@ -656,13 +622,8 @@ async def render_actor_timeline_items(items: list, actor_id: int,
return _timeline_items_sx(items, "actor", actor, actor_id)
# ---------------------------------------------------------------------------
# Public API: Notifications
# ---------------------------------------------------------------------------
async def render_notifications_page(ctx: dict, notifications: list,
actor: Any) -> str:
"""Full page: notifications."""
def _notifications_content_sx(notifications: list) -> str:
"""Build notifications content SX string."""
if not notifications:
notif_sx = sx_call("empty-state", message="No notifications yet.",
cls="text-stone-500")
@@ -673,9 +634,7 @@ async def render_notifications_page(ctx: dict, notifications: list,
items=SxExpr(items_sx),
)
content = sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
return _social_page(ctx, actor, content=content,
title="Notifications \u2014 Rose Ash")
return sx_call("federation-notifications-page", notifs=SxExpr(notif_sx))
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,109 @@
"""Federation defpage setup — registers layouts, page helpers, and loads .sx pages."""
from __future__ import annotations
from typing import Any
def setup_federation_pages() -> None:
"""Register federation-specific layouts, page helpers, and load page definitions."""
_register_federation_layouts()
_register_federation_helpers()
_load_federation_page_files()
def _load_federation_page_files() -> None:
import os
from shared.sx.pages import load_page_dir
load_page_dir(os.path.dirname(__file__), "federation")
# ---------------------------------------------------------------------------
# Layouts
# ---------------------------------------------------------------------------
def _register_federation_layouts() -> None:
from shared.sx.layouts import register_custom_layout
register_custom_layout("social", _social_full, _social_oob)
def _social_full(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, header_child_sx
from sx.sx_components import _social_header_sx
actor = ctx.get("actor")
root_hdr = root_header_sx(ctx)
social_hdr = _social_header_sx(actor)
child = header_child_sx(social_hdr)
return "(<> " + root_hdr + " " + child + ")"
def _social_oob(ctx: dict, **kw: Any) -> str:
from shared.sx.helpers import root_header_sx, sx_call, SxExpr
from sx.sx_components import _social_header_sx
actor = ctx.get("actor")
social_hdr = _social_header_sx(actor)
child_oob = sx_call("oob-header-sx",
parent_id="root-header-child",
row=SxExpr(social_hdr))
root_hdr_oob = root_header_sx(ctx, oob=True)
return "(<> " + child_oob + " " + root_hdr_oob + ")"
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
def _register_federation_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("federation", {
"home-timeline-content": _h_home_timeline_content,
"public-timeline-content": _h_public_timeline_content,
"compose-content": _h_compose_content,
"search-content": _h_search_content,
"following-content": _h_following_content,
"followers-content": _h_followers_content,
"actor-timeline-content": _h_actor_timeline_content,
"notifications-content": _h_notifications_content,
})
def _h_home_timeline_content():
from quart import g
return getattr(g, "home_timeline_content", "")
def _h_public_timeline_content():
from quart import g
return getattr(g, "public_timeline_content", "")
def _h_compose_content():
from quart import g
return getattr(g, "compose_content", "")
def _h_search_content():
from quart import g
return getattr(g, "search_content", "")
def _h_following_content():
from quart import g
return getattr(g, "following_content", "")
def _h_followers_content():
from quart import g
return getattr(g, "followers_content", "")
def _h_actor_timeline_content():
from quart import g
return getattr(g, "actor_timeline_content", "")
def _h_notifications_content():
from quart import g
return getattr(g, "notifications_content", "")

View File

@@ -0,0 +1,49 @@
;; Federation social pages
(defpage home-timeline
:path "/"
:auth :login
:layout :social
:content (home-timeline-content))
(defpage public-timeline
:path "/public"
:auth :public
:layout :social
:content (public-timeline-content))
(defpage compose-form
:path "/compose"
:auth :login
:layout :social
:content (compose-content))
(defpage search
:path "/search"
:auth :public
:layout :social
:content (search-content))
(defpage following-list
:path "/following"
:auth :login
:layout :social
:content (following-content))
(defpage followers-list
:path "/followers"
:auth :login
:layout :social
:content (followers-content))
(defpage actor-timeline
:path "/actor/<int:id>"
:auth :public
:layout :social
:content (actor-timeline-content))
(defpage notifications
:path "/notifications"
:auth :login
:layout :social
:content (notifications-content))

View File

@@ -4,32 +4,32 @@
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
{% if actor %}
<nav class="flex gap-3 text-sm items-center flex-wrap">
<a href="{{ url_for('social.home_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.home_timeline') %}font-bold{% endif %}">
<a href="{{ url_for('social.defpage_home_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_home_timeline') %}font-bold{% endif %}">
Timeline
</a>
<a href="{{ url_for('social.public_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.public_timeline') %}font-bold{% endif %}">
<a href="{{ url_for('social.defpage_public_timeline') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_public_timeline') %}font-bold{% endif %}">
Public
</a>
<a href="{{ url_for('social.compose_form') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.compose_form') %}font-bold{% endif %}">
<a href="{{ url_for('social.defpage_compose_form') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_compose_form') %}font-bold{% endif %}">
Compose
</a>
<a href="{{ url_for('social.following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.following_list') %}font-bold{% endif %}">
<a href="{{ url_for('social.defpage_following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_following_list') %}font-bold{% endif %}">
Following
</a>
<a href="{{ url_for('social.followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.followers_list') %}font-bold{% endif %}">
<a href="{{ url_for('social.defpage_followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_followers_list') %}font-bold{% endif %}">
Followers
</a>
<a href="{{ url_for('social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.search') %}font-bold{% endif %}">
<a href="{{ url_for('social.defpage_search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.defpage_search') %}font-bold{% endif %}">
Search
</a>
<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 %}">
<a href="{{ url_for('social.defpage_notifications') }}"
class="px-2 py-1 rounded hover:bg-stone-200 relative {% if request.path == url_for('social.defpage_notifications') %}font-bold{% endif %}">
Notifications
<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>

View File

@@ -11,7 +11,7 @@
<div class="flex-1 min-w-0">
{% if list_type == "following" and a.id %}
<a href="{{ url_for('social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
<a href="{{ url_for('social.defpage_actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}

View File

@@ -55,7 +55,7 @@
{% endif %}
{% if oid %}
<a href="{{ url_for('social.compose_form', reply_to=oid) }}"
<a href="{{ url_for('social.defpage_compose_form', reply_to=oid) }}"
class="hover:text-stone-700">Reply</a>
{% endif %}
</div>

View File

@@ -11,7 +11,7 @@
<div class="flex-1 min-w-0">
{% if a.id %}
<a href="{{ url_for('social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
<a href="{{ url_for('social.defpage_actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}

View File

@@ -5,10 +5,10 @@
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Search</h1>
<form method="get" action="{{ url_for('social.search') }}" class="mb-6"
sx-get="{{ url_for('social.search_page') }}"
<form method="get" action="{{ url_for('social.defpage_search') }}" class="mb-6"
sx-get="{{ url_for('social.defpage_search_page') }}"
sx-target="#search-results"
sx-push-url="{{ url_for('social.search') }}">
sx-push-url="{{ url_for('social.defpage_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"

View File

@@ -6,7 +6,7 @@
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{{ "Home" if timeline_type == "home" else "Public" }} Timeline</h1>
{% if actor %}
<a href="{{ url_for('social.compose_form') }}"
<a href="{{ url_for('social.defpage_compose_form') }}"
class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">
Compose
</a>