feat: feed ux improvements

This commit is contained in:
2026-05-10 00:16:29 +02:00
parent f4e7d4e359
commit 9f894ebdf2
20 changed files with 1186 additions and 161 deletions

View File

@@ -87,6 +87,34 @@ struct ActivityFeedTemplate<'a> {
has_more: bool,
ctx: &'a HtmlPageContext,
page_items: Vec<PageItem>,
pub filter: String,
pub sort_by: String,
pub search: String,
}
impl<'a> ActivityFeedTemplate<'a> {
pub fn filter_qs(&self) -> String {
let mut parts = vec![
format!("filter={}", self.filter),
format!("sort_by={}", self.sort_by),
];
if !self.search.is_empty() {
let encoded = self.search
.replace(' ', "+")
.replace('#', "%23")
.replace('&', "%26")
.replace('=', "%3D");
parts.push(format!("search={}", encoded));
}
format!("&{}", parts.join("&"))
}
}
pub struct RemoteActorDisplay {
pub handle: String,
pub display_name: String,
pub initial: char,
pub url: String,
}
struct UserSummaryView {
@@ -102,6 +130,7 @@ struct UserSummaryView {
struct UsersTemplate<'a> {
users: Vec<UserSummaryView>,
ctx: &'a HtmlPageContext,
remote_actors: Vec<RemoteActorDisplay>,
}
struct MonthlyRatingRow<'a> {
@@ -320,6 +349,9 @@ impl HtmlRenderer for AskamaHtmlRenderer {
has_more: data.has_more,
ctx: &data.ctx,
page_items: build_page_items(total_pages, current_page),
filter: data.filter,
sort_by: data.sort_by,
search: data.search,
}
.render()
.map_err(|e| e.to_string())
@@ -350,9 +382,23 @@ impl HtmlRenderer for AskamaHtmlRenderer {
}
})
.collect();
let remote_actors = data.remote_actors
.into_iter()
.map(|a| {
let name = a.display_name.unwrap_or_else(|| a.handle.clone());
let initial = name.chars().next().unwrap_or('?');
RemoteActorDisplay {
display_name: name,
initial,
handle: a.handle,
url: a.url,
}
})
.collect();
UsersTemplate {
users,
ctx: &data.ctx,
remote_actors,
}
.render()
.map_err(|e| e.to_string())

View File

@@ -1,5 +1,35 @@
{% extends "base.html" %}
{% block content %}
<form method="get" class="feed-filters" action="/">
{% if ctx.user_email.is_some() %}
<label class="pill{% if filter == "all" %} active{% endif %}">
<input type="radio" name="filter" value="all"
{% if filter == "all" %}checked{% endif %}
onchange="this.form.submit()">
Global
</label>
<label class="pill{% if filter == "following" %} active{% endif %}">
<input type="radio" name="filter" value="following"
{% if filter == "following" %}checked{% endif %}
onchange="this.form.submit()">
Following
</label>
{% endif %}
<div class="feed-controls">
<select name="sort_by" onchange="this.form.submit()">
<option value="date"{% if sort_by == "date" %} selected{% endif %}>Date: newest first</option>
<option value="date_asc"{% if sort_by == "date_asc" %} selected{% endif %}>Date: oldest first</option>
<option value="rating"{% if sort_by == "rating" %} selected{% endif %}>Rating: highest first</option>
<option value="rating_asc"{% if sort_by == "rating_asc" %} selected{% endif %}>Rating: lowest first</option>
</select>
<input type="text" name="search" value="{{ search }}" placeholder="Search movies...">
<button type="submit" class="btn-search">Search</button>
{% if filter != "all" || sort_by != "date" || !search.is_empty() %}
<a href="/" class="clear-filters">Clear</a>
{% endif %}
</div>
<input type="hidden" name="limit" value="{{ limit }}">
</form>
<div class="diary">
{% for entry in entries %}
<article class="entry">
@@ -37,7 +67,7 @@
</div>
{% if ctx.is_current_user(entry.review().user_id().value()) %}
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
<input type="hidden" name="redirect_after" value="/?offset={{ current_offset }}">
<input type="hidden" name="redirect_after" value="/?offset={{ current_offset }}{{ self.filter_qs() }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Delete</button>
</form>
@@ -50,7 +80,7 @@
</div>
<nav class="pagination">
{% if current_offset >= limit %}
<a href="/?offset={{ current_offset - limit }}" class="page-nav">&larr; Prev</a>
<a href="/?offset={{ current_offset - limit }}{{ self.filter_qs() }}" class="page-nav">&larr; Prev</a>
{% endif %}
{% for item in page_items %}
{% if item.is_ellipsis %}
@@ -58,11 +88,11 @@
{% elif item.is_current %}
<span class="page-num current">{{ item.number + 1 }}</span>
{% else %}
<a href="/?offset={{ item.number * limit }}" class="page-num">{{ item.number + 1 }}</a>
<a href="/?offset={{ item.number * limit }}{{ self.filter_qs() }}" class="page-num">{{ item.number + 1 }}</a>
{% endif %}
{% endfor %}
{% if has_more %}
<a href="/?offset={{ current_offset + limit }}" class="page-nav">Next &rarr;</a>
<a href="/?offset={{ current_offset + limit }}{{ self.filter_qs() }}" class="page-nav">Next &rarr;</a>
{% endif %}
</nav>
{% endblock %}

View File

@@ -1,43 +1,59 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ ctx.page_title }}</title>
<meta name="description" content="A personal movie diary — track what you watch, rate and review films.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Movies Diary">
<meta property="og:title" content="{{ ctx.page_title }}">
<meta property="og:url" content="{{ ctx.canonical_url }}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ ctx.page_title }}">
<link rel="canonical" href="{{ ctx.canonical_url }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="icon" type="image/webp" href="/static/logo.webp">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<a href="/" class="site-title">Movies Diary</a>
<nav>
<a href="/">Feed</a>
<a href="/users">Users</a>
<a href="{{ ctx.rss_url }}">RSS</a>
{% if let Some(email) = ctx.user_email %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ ctx.page_title }}</title>
<meta
name="description"
content="A personal movie diary — track what you watch, rate and review films."
/>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Movies Diary" />
<meta property="og:title" content="{{ ctx.page_title }}" />
<meta property="og:url" content="{{ ctx.canonical_url }}" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ ctx.page_title }}" />
<link rel="canonical" href="{{ ctx.canonical_url }}" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/webp" href="/static/logo.webp" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<a href="/" class="site-title">Movies Diary</a>
<nav>
<a href="/">Feed</a>
<a href="/users">Users</a>
{% if let Some(uid) = ctx.user_id %}
<a href="/users/{{ uid }}">Profile</a>
<a href="/reviews/new">Add Review</a>
<a href="/logout">Logout</a>
{% else %}
{% else %}
<a href="/login">Login</a>
{% if ctx.register_enabled %}
<a href="/register">Register</a>
{% endif %}
<a href="/register">Register</a>
{% endif %} {% endif %}
</nav>
</header>
<main>{% block content %}{% endblock %}</main>
<footer class="site-footer">
<span class="footer-made">Made with passion</span>
<span class="footer-sep">·</span>
<a href="/feed.rss" class="footer-link">RSS</a>
{% if let Some(uid) = ctx.user_id %}
<span class="footer-sep">·</span>
<a href="/users/{{ uid }}" class="footer-link">My Profile</a>
<span class="footer-sep">·</span>
<a href="/users/{{ uid }}/feed.rss" class="footer-link">My RSS</a>
{% endif %}
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
<span class="footer-sep">·</span>
<a href="/docs" target="_blank" class="footer-link">API Docs</a>
</footer>
</body>
</html>

View File

@@ -14,5 +14,21 @@
{% else %}
<p class="empty">No users yet.</p>
{% endfor %}
{% if !remote_actors.is_empty() %}
<h2 class="page-title federated-title">Federated</h2>
{% for actor in remote_actors %}
<div class="user-row">
<div class="user-avatar federated-avatar">{{ actor.initial }}</div>
<div class="user-info">
<div class="user-name">{{ actor.display_name }}</div>
<div class="user-meta muted">{{ actor.handle }}</div>
</div>
<a href="{{ actor.url }}" target="_blank" rel="noopener noreferrer" class="btn-secondary">
View profile ↗
</a>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}