Migrate all apps to defpage declarative page routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m41s
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:
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
109
federation/sxc/pages/__init__.py
Normal file
109
federation/sxc/pages/__init__.py
Normal 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", "")
|
||||
49
federation/sxc/pages/social.sx
Normal file
49
federation/sxc/pages/social.sx
Normal 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))
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user