Monorepo: consolidate 7 repos into one
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
33
federation/templates/_email/magic_link.html
Normal file
33
federation/templates/_email/magic_link.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
|
||||
<tr><td>
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
|
||||
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
|
||||
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
|
||||
Click the button below to sign in. This link will expire in 15 minutes.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
|
||||
<a href="{{ link_url }}" target="_blank"
|
||||
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
|
||||
Sign in
|
||||
</a>
|
||||
</td></tr></table>
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
|
||||
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
|
||||
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
|
||||
<p style="margin:0;font-size:12px;color:#a8a29e;">
|
||||
If you did not request this email, you can safely ignore it.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
8
federation/templates/_email/magic_link.txt
Normal file
8
federation/templates/_email/magic_link.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Hello,
|
||||
|
||||
Click this link to sign in:
|
||||
{{ link_url }}
|
||||
|
||||
This link will expire in 15 minutes.
|
||||
|
||||
If you did not request this, you can ignore this email.
|
||||
3
federation/templates/_types/federation/index.html
Normal file
3
federation/templates/_types/federation/index.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
52
federation/templates/_types/social/header/_header.html
Normal file
52
federation/templates/_types/social/header/_header.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='social-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('social.home_timeline') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('social.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 %}">
|
||||
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 %}">
|
||||
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 %}">
|
||||
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 %}">
|
||||
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 %}">
|
||||
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 %}">
|
||||
Notifications
|
||||
<span hx-get="{{ url_for('social.notification_count') }}" hx-trigger="load, every 30s" hx-swap="innerHTML"
|
||||
class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span>
|
||||
</a>
|
||||
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200">
|
||||
@{{ actor.preferred_username }}
|
||||
</a>
|
||||
</nav>
|
||||
{% else %}
|
||||
<nav class="flex gap-3 text-sm items-center">
|
||||
<a href="{{ url_for('identity.choose_username_form') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 font-bold">
|
||||
Choose username
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
10
federation/templates/_types/social/index.html
Normal file
10
federation/templates/_types/social/index.html
Normal 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-header-child', '_types/social/header/_header.html') %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% block social_content %}{% endblock %}
|
||||
{% endblock %}
|
||||
19
federation/templates/auth/check_email.html
Normal file
19
federation/templates/auth/check_email.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Check your email — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
|
||||
<p class="text-stone-600 mb-2">
|
||||
We sent a sign-in link to <strong>{{ email }}</strong>.
|
||||
</p>
|
||||
<p class="text-stone-500 text-sm">
|
||||
Click the link in the email to sign in. The link expires in 15 minutes.
|
||||
</p>
|
||||
{% if email_error %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
|
||||
{{ email_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
36
federation/templates/auth/login.html
Normal file
36
federation/templates/auth/login.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "_types/root/_index.html" %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block title %}Login — Rose Ash{% endblock %}
|
||||
{% block content %}
|
||||
<div class="py-8 max-w-md mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ email | default('') }}"
|
||||
required
|
||||
autofocus
|
||||
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||
>
|
||||
Send magic link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
63
federation/templates/federation/_actor_list_items.html
Normal file
63
federation/templates/federation/_actor_list_items.html
Normal 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('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('social.unfollow') }}"
|
||||
hx-post="{{ url_for('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('social.follow') }}"
|
||||
hx-post="{{ url_for('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('social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
61
federation/templates/federation/_interaction_buttons.html
Normal file
61
federation/templates/federation/_interaction_buttons.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% set oid = item.object_id if item is defined and item.object_id is defined else item_object_id | default('') %}
|
||||
{% set ainbox = item.author_inbox if item is defined and item.author_inbox is defined else item_author_inbox | default('') %}
|
||||
{% set lcount = item.like_count if item is defined and item.like_count is defined else like_count | default(0) %}
|
||||
{% set bcount = item.boost_count if item is defined and item.boost_count is defined else boost_count | default(0) %}
|
||||
{% set liked = item.liked_by_me if item is defined and item.liked_by_me is defined else liked_by_me | default(false) %}
|
||||
{% set boosted = item.boosted_by_me if item is defined and item.boosted_by_me is defined else boosted_by_me | default(false) %}
|
||||
|
||||
<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">
|
||||
{% if liked %}
|
||||
<form hx-post="{{ url_for('social.unlike') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 text-red-500 hover:text-red-600">
|
||||
<span>♥</span> {{ lcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{{ url_for('social.like') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 hover:text-red-500">
|
||||
<span>♡</span> {{ lcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if boosted %}
|
||||
<form hx-post="{{ url_for('social.unboost') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 text-green-600 hover:text-green-700">
|
||||
<span>↻</span> {{ bcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{{ url_for('social.boost') }}"
|
||||
hx-target="#interactions-{{ oid | replace('/', '_') | replace(':', '_') }}"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="object_id" value="{{ oid }}">
|
||||
<input type="hidden" name="author_inbox" value="{{ ainbox }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="flex items-center gap-1 hover:text-green-600">
|
||||
<span>↻</span> {{ bcount }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if oid %}
|
||||
<a href="{{ url_for('social.compose_form', reply_to=oid) }}"
|
||||
class="hover:text-stone-700">Reply</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
42
federation/templates/federation/_notification.html
Normal file
42
federation/templates/federation/_notification.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 {{ 'border-l-4 border-l-stone-400' if not notif.read }}">
|
||||
<div class="flex items-start gap-3">
|
||||
{% if notif.from_actor_icon %}
|
||||
<img src="{{ notif.from_actor_icon }}" alt="" class="w-8 h-8 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs">
|
||||
{{ notif.from_actor_name[0] | upper if notif.from_actor_name else '?' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="text-sm">
|
||||
<span class="font-semibold">{{ notif.from_actor_name }}</span>
|
||||
<span class="text-stone-500">
|
||||
@{{ notif.from_actor_username }}{% if notif.from_actor_domain %}@{{ notif.from_actor_domain }}{% endif %}
|
||||
</span>
|
||||
|
||||
{% if notif.notification_type == "follow" %}
|
||||
<span class="text-stone-600">followed you</span>
|
||||
{% elif notif.notification_type == "like" %}
|
||||
<span class="text-stone-600">liked your post</span>
|
||||
{% elif notif.notification_type == "boost" %}
|
||||
<span class="text-stone-600">boosted your post</span>
|
||||
{% elif notif.notification_type == "mention" %}
|
||||
<span class="text-stone-600">mentioned you</span>
|
||||
{% elif notif.notification_type == "reply" %}
|
||||
<span class="text-stone-600">replied to your post</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if notif.target_content_preview %}
|
||||
<div class="text-sm text-stone-500 mt-1 truncate">
|
||||
{{ notif.target_content_preview }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-xs text-stone-400 mt-1">
|
||||
{{ notif.created_at.strftime('%b %d, %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
52
federation/templates/federation/_post_card.html
Normal file
52
federation/templates/federation/_post_card.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<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 %}
|
||||
|
||||
{% if item.url and item.post_type == "remote" %}
|
||||
<a href="{{ item.url }}" target="_blank" rel="noopener" class="text-sm text-stone-400 hover:underline mt-1 inline-block">
|
||||
original
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if actor %}
|
||||
<div id="interactions-{{ item.object_id | replace('/', '_') | replace(':', '_') }}">
|
||||
{% include "federation/_interaction_buttons.html" with context %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
61
federation/templates/federation/_search_results.html
Normal file
61
federation/templates/federation/_search_results.html
Normal 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('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('social.unfollow') }}"
|
||||
hx-post="{{ url_for('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('social.follow') }}"
|
||||
hx-post="{{ url_for('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('social.search_page', q=query, page=page + 1) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
18
federation/templates/federation/_timeline_items.html
Normal file
18
federation/templates/federation/_timeline_items.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% for item in items %}
|
||||
{% include "federation/_post_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if items %}
|
||||
{% set last = items[-1] %}
|
||||
{% if timeline_type == "actor" %}
|
||||
<div hx-get="{{ url_for('social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% else %}
|
||||
<div hx-get="{{ url_for('social.' ~ timeline_type ~ '_timeline_page', before=last.published.isoformat()) }}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
27
federation/templates/federation/account.html
Normal file
27
federation/templates/federation/account.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
{% block title %}Account — Rose Ash{% endblock %}
|
||||
{% block social_content %}
|
||||
<div class="py-8">
|
||||
<h1 class="text-2xl font-bold mb-4">Account</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<p><strong>Email:</strong> {{ g.user.email }}</p>
|
||||
{% if actor %}
|
||||
<p class="mt-2"><strong>Username:</strong> @{{ actor.preferred_username }}</p>
|
||||
<p class="mt-1">
|
||||
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
|
||||
class="text-blue-600 hover:underline">
|
||||
View profile
|
||||
</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="mt-4">
|
||||
<a href="{{ url_for('identity.choose_username_form') }}"
|
||||
class="bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">
|
||||
Choose a username to start publishing
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
45
federation/templates/federation/actor_card.html
Normal file
45
federation/templates/federation/actor_card.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
{% if result.icon_url %}
|
||||
<img src="{{ result.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">
|
||||
{{ result.preferred_username[0] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-bold text-lg">{{ result.display_name or result.preferred_username }}</span>
|
||||
<span class="text-stone-500">@{{ result.preferred_username }}@{{ result.domain }}</span>
|
||||
</div>
|
||||
|
||||
{% if result.summary %}
|
||||
<div class="text-sm text-stone-600 mt-1 prose prose-sm prose-stone max-w-none">
|
||||
{{ result.summary | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if actor %}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<form method="post" action="{{ url_for('social.follow') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ result.actor_url }}">
|
||||
<button type="submit"
|
||||
class="bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700">
|
||||
Follow
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('social.unfollow') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ result.actor_url }}">
|
||||
<button type="submit"
|
||||
class="border border-stone-300 text-stone-700 px-4 py-1.5 rounded text-sm hover:bg-stone-100">
|
||||
Unfollow
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
53
federation/templates/federation/actor_timeline.html
Normal file
53
federation/templates/federation/actor_timeline.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "_types/social/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('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('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 "federation/_timeline_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
54
federation/templates/federation/choose_username.html
Normal file
54
federation/templates/federation/choose_username.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
{% block title %}Choose Username — Rose Ash{% endblock %}
|
||||
{% block social_content %}
|
||||
<div class="py-8 max-w-md mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-2">Choose your username</h1>
|
||||
<p class="text-stone-600 mb-6">
|
||||
This will be your identity on the fediverse:
|
||||
<strong>@username@{{ config.get('ap_domain', 'rose-ash.com') }}</strong>
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium mb-1">Username</label>
|
||||
<div class="flex items-center">
|
||||
<span class="text-stone-400 mr-1">@</span>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
value="{{ username | default('') }}"
|
||||
pattern="[a-z][a-z0-9_]{2,31}"
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
hx-get="{{ url_for('identity.check_username') }}"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#username-status"
|
||||
hx-include="[name='username']"
|
||||
>
|
||||
</div>
|
||||
<div id="username-status" class="text-sm mt-1"></div>
|
||||
<p class="text-xs text-stone-400 mt-1">
|
||||
3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% endblock %}
|
||||
34
federation/templates/federation/compose.html
Normal file
34
federation/templates/federation/compose.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
|
||||
{% block title %}Compose — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Compose</h1>
|
||||
|
||||
<form method="post" action="{{ url_for('social.compose_submit') }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if reply_to %}
|
||||
<input type="hidden" name="in_reply_to" value="{{ reply_to }}">
|
||||
<div class="text-sm text-stone-500">
|
||||
Replying to <span class="font-mono">{{ reply_to }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<textarea name="content" rows="6" maxlength="5000" required
|
||||
class="w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
placeholder="What's on your mind?"></textarea>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<select name="visibility" class="border border-stone-300 rounded px-3 py-1.5 text-sm">
|
||||
<option value="public">Public</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="followers">Followers only</option>
|
||||
</select>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
12
federation/templates/federation/followers.html
Normal file
12
federation/templates/federation/followers.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "_types/social/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 "federation/_actor_list_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
federation/templates/federation/following.html
Normal file
13
federation/templates/federation/following.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "_types/social/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 "federation/_actor_list_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
federation/templates/federation/notifications.html
Normal file
17
federation/templates/federation/notifications.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
|
||||
{% block title %}Notifications — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Notifications</h1>
|
||||
|
||||
{% if not notifications %}
|
||||
<p class="text-stone-500">No notifications yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="space-y-2">
|
||||
{% for notif in notifications %}
|
||||
{% include "federation/_notification.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
32
federation/templates/federation/profile.html
Normal file
32
federation/templates/federation/profile.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %}
|
||||
{% block social_content %}
|
||||
<div class="py-8">
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ actor.display_name or actor.preferred_username }}</h1>
|
||||
<p class="text-stone-500">@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}</p>
|
||||
{% if actor.summary %}
|
||||
<p class="mt-2">{{ actor.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-bold mb-4">Activities ({{ total }})</h2>
|
||||
{% if activities %}
|
||||
<div class="space-y-4">
|
||||
{% for a in activities %}
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-medium">{{ a.activity_type }}</span>
|
||||
<span class="text-sm text-stone-400">{{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }}</span>
|
||||
</div>
|
||||
{% if a.object_type %}
|
||||
<span class="text-sm text-stone-500">{{ a.object_type }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-stone-500">No activities yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
32
federation/templates/federation/search.html
Normal file
32
federation/templates/federation/search.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "_types/social/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('social.search') }}" class="mb-6"
|
||||
hx-get="{{ url_for('social.search_page') }}"
|
||||
hx-target="#search-results"
|
||||
hx-push-url="{{ url_for('social.search') }}">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
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 "federation/_search_results.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
19
federation/templates/federation/timeline.html
Normal file
19
federation/templates/federation/timeline.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
|
||||
{% block title %}{{ "Home" if timeline_type == "home" else "Public" }} Timeline — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<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') }}"
|
||||
class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">
|
||||
Compose
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="timeline">
|
||||
{% include "federation/_timeline_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user