Add per-app AP social UI for blog, market, and events

Lightweight social pages (search, follow/unfollow, followers, following,
actor timeline) auto-registered for AP-enabled apps via shared blueprint.
Federation keeps the full social hub. Followers scoped per app_domain;
post cards show "View on Hub" link instead of interaction buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-25 08:45:59 +00:00
parent 8680ec37d6
commit 99ab363cfd
15 changed files with 646 additions and 4 deletions

View File

@@ -0,0 +1,42 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='social-lite-row', oob=oob) %}
<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('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ url_for('ap_social.following_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.following_list') %}font-bold{% endif %}">
Following
</a>
<a href="{{ url_for('ap_social.followers_list') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.followers_list') %}font-bold{% endif %}">
Followers
</a>
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
class="px-2 py-1 rounded hover:bg-stone-200">
@{{ actor.preferred_username }}
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% else %}
<nav class="flex gap-3 text-sm items-center">
<a href="{{ url_for('ap_social.search') }}"
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
Search
</a>
<a href="{{ federation_url('/social/') }}"
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
Hub
</a>
</nav>
{% endif %}
</div>
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,10 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('social-lite-header-child', '_types/social_lite/header/_header.html') %}
{% endcall %}
{% endblock %}
{% block content %}
{% block social_content %}{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if list_type == "following" and a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<a href="https://{{ a.domain }}/@{{ a.preferred_username }}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if list_type == "following" or a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
hx-post="{{ url_for('ap_social.unfollow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
hx-post="{{ url_for('ap_social.follow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow Back
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div hx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -0,0 +1,53 @@
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
{% if item.boosted_by %}
<div class="text-sm text-stone-500 mb-2">
Boosted by {{ item.boosted_by }}
</div>
{% endif %}
<div class="flex items-start gap-3">
{% if item.actor_icon %}
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
{% else %}
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
<span class="text-sm text-stone-500">
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
</span>
<span class="text-sm text-stone-400 ml-auto">
{% if item.published %}
{{ item.published.strftime('%b %d, %H:%M') }}
{% endif %}
</span>
</div>
{% if item.summary %}
<details class="mt-2">
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
</details>
{% else %}
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
{% endif %}
<div class="mt-2 flex gap-3 text-sm text-stone-400">
{% if item.url and item.post_type == "remote" %}
<a href="{{ item.url }}" target="_blank" rel="noopener" class="hover:underline">
original
</a>
{% endif %}
{% if item.object_id %}
<a href="{{ federation_url('/social/') }}" class="hover:underline">
View on Hub
</a>
{% endif %}
</div>
</div>
</div>
</article>

View File

@@ -0,0 +1,61 @@
{% for a in actors %}
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
{% if a.icon_url %}
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
{% else %}
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
{{ (a.display_name or a.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1 min-w-0">
{% if a.id %}
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
{{ a.display_name or a.preferred_username }}
</a>
{% else %}
<span class="font-semibold text-stone-900">{{ a.display_name or a.preferred_username }}</span>
{% endif %}
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
{% if a.summary %}
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if a.actor_url in (followed_urls or []) %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
hx-post="{{ url_for('ap_social.unfollow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}"
hx-post="{{ url_for('ap_social.follow') }}"
hx-target="closest article"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
{% if actors | length >= 20 %}
<div hx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}

View File

@@ -0,0 +1,13 @@
{% for item in items %}
{% include "social/_post_card.html" %}
{% endfor %}
{% if items %}
{% set last = items[-1] %}
{% if timeline_type == "actor" %}
<div hx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
hx-trigger="revealed"
hx-swap="outerHTML">
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,53 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
{% block social_content %}
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">
<div class="flex items-center gap-4">
{% if remote_actor.icon_url %}
<img src="{{ remote_actor.icon_url }}" alt="" class="w-16 h-16 rounded-full">
{% else %}
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
{{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
</div>
{% endif %}
<div class="flex-1">
<h1 class="text-xl font-bold">{{ remote_actor.display_name or remote_actor.preferred_username }}</h1>
<div class="text-stone-500">@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}</div>
{% if remote_actor.summary %}
<div class="text-sm text-stone-600 mt-2">{{ remote_actor.summary | safe }}</div>
{% endif %}
</div>
{% if actor %}
<div class="flex-shrink-0">
{% if is_following %}
<form method="post" action="{{ url_for('ap_social.unfollow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">
Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('ap_social.follow') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">
Follow
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div id="timeline">
{% set timeline_type = "actor" %}
{% set actor_id = remote_actor.id %}
{% include "social/_timeline_items.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Followers — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "followers" %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Following — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({{ total }})</span></h1>
<div id="actor-list">
{% set list_type = "following" %}
{% set followed_urls = [] %}
{% include "social/_actor_list_items.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "_types/social_lite/index.html" %}
{% block title %}Search — Rose Ash{% endblock %}
{% block social_content %}
<h1 class="text-2xl font-bold mb-6">Search</h1>
<form method="get" action="{{ url_for('ap_social.search') }}" class="mb-6"
hx-get="{{ url_for('ap_social.search_page') }}"
hx-target="#search-results"
hx-push-url="{{ url_for('ap_social.search') }}">
<div class="flex gap-2">
<input type="text" name="q" value="{{ query }}"
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
placeholder="Search users or @user@instance.tld">
<button type="submit"
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
Search
</button>
</div>
</form>
{% if query and total %}
<p class="text-sm text-stone-500 mb-4">{{ total }} result{{ 's' if total != 1 }} for <strong>{{ query }}</strong></p>
{% elif query %}
<p class="text-stone-500 mb-4">No results found for <strong>{{ query }}</strong></p>
{% endif %}
<div id="search-results">
{% include "social/_search_results.html" %}
</div>
{% endblock %}