federation refinement

This commit is contained in:
2026-05-09 13:53:45 +02:00
parent 86909ecede
commit 0d3c2c937d
56 changed files with 1513 additions and 544 deletions

View File

@@ -5,7 +5,7 @@ use application::ports::{
NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
};
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserSummary,
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats,
UserTrends, collections::Paginated,
};
@@ -81,10 +81,18 @@ struct ActivityFeedTemplate<'a> {
page_items: Vec<PageItem>,
}
struct UserSummaryView {
user_id: uuid::Uuid,
display_name: String,
initial: char,
avg_rating_display: String,
total_movies: i64,
}
#[derive(Template)]
#[template(path = "users.html")]
struct UsersTemplate<'a> {
users: &'a [UserSummary],
users: Vec<UserSummaryView>,
ctx: &'a HtmlPageContext,
}
@@ -100,6 +108,9 @@ struct ProfileTemplate<'a> {
profile_display_name: String,
profile_user_id: uuid::Uuid,
stats: &'a UserStats,
avg_rating_display: String,
favorite_director_display: String,
most_active_month_display: String,
view: &'a str,
entries: Option<&'a Paginated<DiaryEntry>>,
current_offset: u32,
@@ -113,6 +124,7 @@ struct ProfileTemplate<'a> {
is_own_profile: bool,
error: Option<String>,
following_count: usize,
pending_followers: Vec<RemoteActorData>,
}
struct RemoteActorData {
@@ -255,8 +267,23 @@ impl HtmlRenderer for AskamaHtmlRenderer {
}
fn render_users_page(&self, data: UsersPageData) -> Result<String, String> {
let users: Vec<UserSummaryView> = data.users.iter().map(|u| {
let email = u.email();
let display_name = email.split('@').next().unwrap_or(email).to_string();
let initial = display_name.chars().next().unwrap_or('?').to_ascii_uppercase();
let avg_rating_display = u.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string());
UserSummaryView {
user_id: u.user_id.value(),
display_name,
initial,
avg_rating_display,
total_movies: u.total_movies,
}
}).collect();
UsersTemplate {
users: &data.users,
users,
ctx: &data.ctx,
}
.render()
@@ -279,11 +306,25 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.map(|e| if e.limit > 0 { ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 } else { 0 })
.unwrap_or(0);
let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 };
let avg_rating_display = data.stats.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string());
let favorite_director_display = data.stats.favorite_director
.as_deref()
.unwrap_or("")
.to_string();
let most_active_month_display = data.stats.most_active_month
.as_deref()
.unwrap_or("")
.to_string();
ProfileTemplate {
ctx: &data.ctx,
profile_display_name,
profile_user_id: data.profile_user_id,
stats: &data.stats,
avg_rating_display,
favorite_director_display,
most_active_month_display,
view: &data.view,
entries: data.entries.as_ref(),
current_offset: data.current_offset,
@@ -297,6 +338,11 @@ impl HtmlRenderer for AskamaHtmlRenderer {
is_own_profile: data.is_own_profile,
error: data.error,
following_count: data.following_count,
pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData {
handle: a.handle,
url: a.url,
display_name: a.display_name,
}).collect(),
}
.render()
.map_err(|e| e.to_string())

View File

@@ -25,12 +25,14 @@
<div class="comment">{{ comment.value() }}</div>
{% endif %}
<div class="feed-meta">
<a href="/users/{{ entry.review().user_id().value() }}" class="feed-user">{{ entry.user_display_name() }}</a>
<span class="feed-time">{{ entry.review().watched_at().format("%b %-d, %Y") }}</span>
{% match entry.review().source() %}
{% when ReviewSource::Remote with { actor_url } %}
<span class="remote-badge" title="{{ actor_url }}">&#8599; federated</span>
<a href="{{ actor_url }}" class="feed-user" target="_blank" rel="noopener noreferrer">{{ entry.user_display_name() }}</a>
<span class="feed-time">{{ entry.review().watched_at().format("%b %-d, %Y") }}</span>
<span class="remote-badge">&#8599; federated</span>
{% when ReviewSource::Local %}
<a href="/users/{{ entry.review().user_id().value() }}" class="feed-user">{{ entry.user_display_name() }}</a>
<span class="feed-time">{{ entry.review().watched_at().format("%b %-d, %Y") }}</span>
{% endmatch %}
</div>
{% if ctx.is_current_user(entry.review().user_id().value()) %}

View File

@@ -10,15 +10,15 @@
<div class="stat-label">movies</div>
</div>
<div class="stat-tile">
<div class="stat-value">{{ stats.avg_rating_display() }}★</div>
<div class="stat-value">{{ avg_rating_display }}★</div>
<div class="stat-label">avg rating</div>
</div>
<div class="stat-tile">
<div class="stat-value">{{ stats.favorite_director_display() }}</div>
<div class="stat-value">{{ favorite_director_display }}</div>
<div class="stat-label">fav director</div>
</div>
<div class="stat-tile">
<div class="stat-value">{{ stats.most_active_month_display() }}</div>
<div class="stat-value">{{ most_active_month_display }}</div>
<div class="stat-label">most active</div>
</div>
</div>
@@ -36,6 +36,27 @@
{% endif %}
</section>
<a href="/users/{{ profile_user_id }}/following-list">View following ({{ following_count }})</a>
{% if !pending_followers.is_empty() %}
<section class="pending-followers">
<h3>Pending follow requests ({{ pending_followers.len() }})</h3>
<ul class="pending-list">
{% for actor in pending_followers %}
<li class="pending-item">
<span class="pending-handle"><strong>{{ actor.handle }}</strong></span>
<a href="{{ actor.url }}" class="pending-url" target="_blank" rel="noopener noreferrer">{{ actor.url }}</a>
<form method="POST" action="/users/{{ profile_user_id }}/followers/accept" class="inline-form">
<input type="hidden" name="actor_url" value="{{ actor.url }}">
<button type="submit" class="btn-accept">Accept</button>
</form>
<form method="POST" action="/users/{{ profile_user_id }}/followers/reject" class="inline-form">
<input type="hidden" name="actor_url" value="{{ actor.url }}">
<button type="submit" class="btn-reject">Reject</button>
</form>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endif %}
<div class="view-tabs">

View File

@@ -5,6 +5,12 @@
<p class="error">{{ err }}</p>
{% endif %}
<form method="POST" action="/register">
<label>
Username<br>
<small>230 chars: letters, digits, underscores, hyphens. This becomes your ActivityPub handle.</small><br>
<input type="text" name="username" required autocomplete="username"
pattern="[a-z0-9_\-]{2,30}" title="230 lowercase letters, digits, underscores or hyphens">
</label>
<label>
Email<br>
<input type="email" name="email" required autocomplete="email">

View File

@@ -4,12 +4,12 @@
<h2 class="page-title">Members</h2>
{% for user in users %}
<div class="user-row">
<div class="user-avatar">{{ user.initial() }}</div>
<div class="user-avatar">{{ user.initial }}</div>
<div class="user-info">
<div class="user-name">{{ user.display_name() }}</div>
<div class="user-meta">{{ user.total_movies }} movies · avg {{ user.avg_rating_display() }}★</div>
<div class="user-name">{{ user.display_name }}</div>
<div class="user-meta">{{ user.total_movies }} movies · avg {{ user.avg_rating_display }}★</div>
</div>
<a href="/users/{{ user.user_id.value() }}" class="btn-secondary">View profile →</a>
<a href="/users/{{ user.user_id }}" class="btn-secondary">View profile →</a>
</div>
{% else %}
<p class="empty">No users yet.</p>