From 9f894ebdf2fb45cbd88c4cfe23cf56f5f9deb88e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 10 May 2026 00:16:29 +0200 Subject: [PATCH] feat: feed ux improvements --- Cargo.lock | 1 + crates/adapters/sqlite-federation/Cargo.toml | 4 + crates/adapters/sqlite-federation/src/lib.rs | 133 +++++ crates/adapters/sqlite/src/lib.rs | 245 +++++++-- crates/adapters/template-askama/src/lib.rs | 46 ++ .../templates/activity_feed.html | 38 +- .../template-askama/templates/base.html | 88 ++-- .../template-askama/templates/users.html | 16 + crates/application/src/ports.rs | 4 + crates/application/src/queries.rs | 7 +- .../src/use_cases/get_activity_feed.rs | 11 +- crates/domain/src/ports.rs | 55 ++ crates/presentation/src/dtos.rs | 12 + crates/presentation/src/extractors.rs | 24 + crates/presentation/src/handlers.rs | 125 ++++- crates/presentation/src/main.rs | 2 + crates/presentation/src/routes.rs | 36 +- crates/presentation/src/state.rs | 1 + crates/presentation/tests/api_test.rs | 17 + static/style.css | 482 +++++++++++++++--- 20 files changed, 1186 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f494c9f..2b5c9a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4260,6 +4260,7 @@ dependencies = [ "chrono", "domain", "sqlx", + "tokio", "tracing", "uuid", ] diff --git a/crates/adapters/sqlite-federation/Cargo.toml b/crates/adapters/sqlite-federation/Cargo.toml index a76f924..d2ab772 100644 --- a/crates/adapters/sqlite-federation/Cargo.toml +++ b/crates/adapters/sqlite-federation/Cargo.toml @@ -13,3 +13,7 @@ uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +uuid = { workspace = true } diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 7f8d45d..a4933a6 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -494,3 +494,136 @@ impl RemoteReviewRepository for SqliteFederationRepository { Ok(()) } } + +#[async_trait] +impl domain::ports::SocialQueryPort for SqliteFederationRepository { + async fn get_accepted_following_urls( + &self, + user_id: uuid::Uuid, + ) -> Result, domain::errors::DomainError> { + let user_id_str = user_id.to_string(); + let rows = sqlx::query_scalar::<_, String>( + "SELECT remote_actor_url FROM ap_following + WHERE local_user_id = ? AND status = 'accepted'", + ) + .bind(&user_id_str) + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + Ok(rows) + } + + async fn list_all_followed_remote_actors( + &self, + ) -> Result, domain::errors::DomainError> { + let rows = sqlx::query_as::<_, (String, String, Option)>( + "SELECT DISTINCT ar.url, ar.handle, ar.display_name + FROM ap_remote_actors ar + JOIN ap_following f ON f.remote_actor_url = ar.url + WHERE f.status = 'accepted'", + ) + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + + Ok(rows + .into_iter() + .map(|(url, handle, display_name)| domain::ports::RemoteActorInfo { + url, + handle, + display_name, + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ports::SocialQueryPort; + use sqlx::SqlitePool; + + async fn setup_db(pool: &SqlitePool) { + sqlx::query( + "CREATE TABLE IF NOT EXISTS ap_remote_actors ( + url TEXT PRIMARY KEY, + handle TEXT NOT NULL, + inbox_url TEXT NOT NULL, + shared_inbox_url TEXT, + display_name TEXT, + fetched_at TEXT NOT NULL + )", + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + "CREATE TABLE IF NOT EXISTS ap_following ( + local_user_id TEXT NOT NULL, + remote_actor_url TEXT NOT NULL, + follow_activity_id TEXT NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (local_user_id, remote_actor_url) + )", + ) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_get_accepted_following_urls_returns_only_accepted() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup_db(&pool).await; + let repo = SqliteFederationRepository::new(pool.clone()); + let user_id = uuid::Uuid::new_v4(); + + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), + (?, 'https://other.social/users/bob', 'act2', 'pending')", + ) + .bind(user_id.to_string()) + .bind(user_id.to_string()) + .execute(&pool) + .await + .unwrap(); + + let urls = repo.get_accepted_following_urls(user_id).await.unwrap(); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0], "https://other.social/users/alice"); + } + + #[tokio::test] + async fn test_list_all_followed_remote_actors_deduplicates() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup_db(&pool).await; + let repo = SqliteFederationRepository::new(pool.clone()); + let user1 = uuid::Uuid::new_v4(); + let user2 = uuid::Uuid::new_v4(); + + sqlx::query( + "INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name) + VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')", + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status) + VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'), + (?, 'https://other.social/users/alice', 'act2', 'accepted')", + ) + .bind(user1.to_string()) + .bind(user2.to_string()) + .execute(&pool) + .await + .unwrap(); + + let actors = repo.list_all_followed_remote_actors().await.unwrap(); + assert_eq!(actors.len(), 1); + assert_eq!(actors[0].handle, "alice@other.social"); + } +} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 4a83445..7e8a362 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -214,31 +214,6 @@ impl SqliteMovieRepository { .map_err(Self::map_err) } - async fn count_feed_entries(&self) -> Result { - sqlx::query_scalar!("SELECT COUNT(*) FROM reviews") - .fetch_one(&self.pool) - .await - .map_err(Self::map_err) - } - - async fn fetch_feed_rows(&self, limit: i64, offset: i64) -> Result, DomainError> { - sqlx::query_as!( - FeedRow, - r#"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, - COALESCE(u.email, r.remote_actor_url) AS "user_email!: String" - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - LEFT JOIN users u ON u.id = r.user_id - ORDER BY r.watched_at DESC - LIMIT ? OFFSET ?"#, - limit, offset - ) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err) - } - async fn fetch_user_totals(&self, user_id: &str) -> Result { sqlx::query_as!( UserTotalsRow, @@ -520,13 +495,115 @@ impl DiaryRepository for SqliteMovieRepository { &self, page: &PageParams, ) -> Result, DomainError> { + self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None) + .await + } + + async fn query_activity_feed_filtered( + &self, + page: &PageParams, + sort_by: &domain::ports::FeedSortBy, + search: Option<&str>, + following: Option<&domain::ports::FollowingFilter>, + ) -> Result, DomainError> { + use domain::ports::FeedSortBy; + let limit = page.limit as i64; let offset = page.offset as i64; + let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let (total, rows) = tokio::try_join!( - self.count_feed_entries(), - self.fetch_feed_rows(limit, offset) - )?; + let mut where_parts = vec!["1=1".to_string()]; + + if has_search { + where_parts.push("m.title LIKE '%' || ? || '%'".to_string()); + } + + if let Some(f) = following { + let local_in = if f.local_user_ids.is_empty() { + "SELECT NULL WHERE 0".to_string() + } else { + f.local_user_ids + .iter() + .map(|_| "?") + .collect::>() + .join(",") + }; + let remote_in = if f.remote_actor_urls.is_empty() { + "SELECT NULL WHERE 0".to_string() + } else { + f.remote_actor_urls + .iter() + .map(|_| "?") + .collect::>() + .join(",") + }; + where_parts.push(format!( + "(r.user_id IN ({}) OR r.remote_actor_url IN ({}))", + local_in, remote_in + )); + } + + let order_clause = match sort_by { + FeedSortBy::Date => "r.watched_at DESC", + FeedSortBy::DateAsc => "r.watched_at ASC", + FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC", + FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC", + }; + + let where_clause = where_parts.join(" AND "); + + let count_sql = format!( + "SELECT COUNT(*) FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE {}", + where_clause + ); + + let select_sql = format!( + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, + r.watched_at, r.created_at, r.remote_actor_url, + COALESCE(u.email, r.remote_actor_url) AS user_email + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + LEFT JOIN users u ON u.id = r.user_id + WHERE {} + ORDER BY {} + LIMIT ? OFFSET ?", + where_clause, order_clause + ); + + macro_rules! bind_filter_params { + ($q:expr) => {{ + let mut q = $q; + if has_search { + q = q.bind(search.unwrap()); + } + if let Some(f) = following { + for uid in &f.local_user_ids { + q = q.bind(uid.to_string()); + } + for url in &f.remote_actor_urls { + q = q.bind(url.as_str()); + } + } + q + }}; + } + + let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); + let total = count_q + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; + + let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); + let rows = rows_q + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; let items = rows .into_iter() @@ -672,3 +749,113 @@ impl StatsRepository for SqliteMovieRepository { }) } } + +#[cfg(test)] +mod feed_filter_tests { + use super::*; + use domain::{ + models::collections::PageParams, + ports::{DiaryRepository, FeedSortBy, FollowingFilter}, + }; + use sqlx::SqlitePool; + + async fn setup(pool: &SqlitePool) { + sqlx::migrate!("./migrations").run(pool).await.unwrap(); + + // carol is a remote actor; we still need a non-null user_id for the schema, + // so we create a local "ghost" user and link the remote review via remote_actor_url. + sqlx::query( + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES + ('11111111-1111-1111-1111-111111111111', 'alice@example.com', 'alice', 'hash', '2024-01-01 00:00:00'), + ('22222222-2222-2222-2222-222222222222', 'bob@example.com', 'bob', 'hash', '2024-01-01 00:00:00'), + ('33333333-3333-3333-3333-333333333333', 'carol@remote.social', 'carol', 'hash', '2024-01-01 00:00:00')", + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO movies (id, title, release_year) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Inception', 2010), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Interstellar', 2014), + ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'Dune', 2021)", + ) + .execute(pool) + .await + .unwrap(); + + // carol's review: local user_id=33333333, remote_actor_url set → remote review + sqlx::query( + "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) VALUES + ('a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 5, '2024-01-01 00:00:00', '2024-01-01 00:00:00', NULL), + ('b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222', 3, '2024-01-02 00:00:00', '2024-01-02 00:00:00', NULL), + ('c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '33333333-3333-3333-3333-333333333333', 4, '2024-01-03 00:00:00', '2024-01-03 00:00:00', 'https://remote.social/users/carol')", + ) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_sort_by_rating_descending() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup(&pool).await; + let repo = SqliteMovieRepository::new(pool); + + let page = PageParams::new(Some(10), Some(0)).unwrap(); + let result = repo + .query_activity_feed_filtered(&page, &FeedSortBy::Rating, None, None) + .await + .unwrap(); + + let ratings: Vec = result + .items + .iter() + .map(|e| e.review().rating().value()) + .collect(); + assert_eq!(ratings, vec![5, 4, 3]); + } + + #[tokio::test] + async fn test_search_by_title() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup(&pool).await; + let repo = SqliteMovieRepository::new(pool); + + let page = PageParams::new(Some(10), Some(0)).unwrap(); + let result = repo + .query_activity_feed_filtered(&page, &FeedSortBy::Date, Some("Dune"), None) + .await + .unwrap(); + + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0].movie().title().value(), "Dune"); + } + + #[tokio::test] + async fn test_following_filter() { + let pool = SqlitePool::connect(":memory:").await.unwrap(); + setup(&pool).await; + let repo = SqliteMovieRepository::new(pool); + + let filter = FollowingFilter { + local_user_ids: vec![uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111") + .unwrap()], + remote_actor_urls: vec!["https://remote.social/users/carol".to_string()], + }; + let page = PageParams::new(Some(10), Some(0)).unwrap(); + let result = repo + .query_activity_feed_filtered(&page, &FeedSortBy::Date, None, Some(&filter)) + .await + .unwrap(); + + assert_eq!(result.items.len(), 2); // alice + carol, NOT bob + let titles: Vec = result + .items + .iter() + .map(|e| e.movie().title().value().to_string()) + .collect(); + assert!(titles.contains(&"Inception".to_string())); + assert!(titles.contains(&"Dune".to_string())); + } +} diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 60a0f98..6e6713e 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -87,6 +87,34 @@ struct ActivityFeedTemplate<'a> { has_more: bool, ctx: &'a HtmlPageContext, page_items: Vec, + 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, ctx: &'a HtmlPageContext, + remote_actors: Vec, } 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()) diff --git a/crates/adapters/template-askama/templates/activity_feed.html b/crates/adapters/template-askama/templates/activity_feed.html index bb0160e..f33a3ba 100644 --- a/crates/adapters/template-askama/templates/activity_feed.html +++ b/crates/adapters/template-askama/templates/activity_feed.html @@ -1,5 +1,35 @@ {% extends "base.html" %} {% block content %} +
+ {% if ctx.user_email.is_some() %} + + + {% endif %} +
+ + + + {% if filter != "all" || sort_by != "date" || !search.is_empty() %} + Clear + {% endif %} +
+ +
{% for entry in entries %}
@@ -37,7 +67,7 @@
{% if ctx.is_current_user(entry.review().user_id().value()) %}
- +
@@ -50,7 +80,7 @@ {% endblock %} diff --git a/crates/adapters/template-askama/templates/base.html b/crates/adapters/template-askama/templates/base.html index 5535474..2636eb5 100644 --- a/crates/adapters/template-askama/templates/base.html +++ b/crates/adapters/template-askama/templates/base.html @@ -1,43 +1,59 @@ - + - - - - {{ ctx.page_title }} - - - - - - - - - - - - - - - -
- Movies Diary - -
-
- {% block content %}{% endblock %} -
- + · + API Docs + + diff --git a/crates/adapters/template-askama/templates/users.html b/crates/adapters/template-askama/templates/users.html index d9a3b84..6142e7b 100644 --- a/crates/adapters/template-askama/templates/users.html +++ b/crates/adapters/template-askama/templates/users.html @@ -14,5 +14,21 @@ {% else %}

No users yet.

{% endfor %} + + {% if !remote_actors.is_empty() %} +

Federated

+ {% for actor in remote_actors %} +
+
{{ actor.initial }}
+ + + View profile ↗ + +
+ {% endfor %} + {% endif %} {% endblock %} diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 29d19dc..0441271 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -48,11 +48,15 @@ pub struct ActivityFeedPageData { pub current_offset: u32, pub has_more: bool, pub limit: u32, + pub filter: String, + pub sort_by: String, + pub search: String, } pub struct UsersPageData { pub ctx: HtmlPageContext, pub users: Vec, + pub remote_actors: Vec, } pub struct ProfilePageData { diff --git a/crates/application/src/queries.rs b/crates/application/src/queries.rs index 284d957..3bc22e3 100644 --- a/crates/application/src/queries.rs +++ b/crates/application/src/queries.rs @@ -14,8 +14,11 @@ pub struct GetReviewHistoryQuery { } pub struct GetActivityFeedQuery { - pub limit: Option, - pub offset: Option, + pub limit: u32, + pub offset: u32, + pub sort_by: domain::ports::FeedSortBy, + pub search: Option, + pub following: Option, } pub struct GetUsersQuery; diff --git a/crates/application/src/use_cases/get_activity_feed.rs b/crates/application/src/use_cases/get_activity_feed.rs index 1a9abd1..19bcddf 100644 --- a/crates/application/src/use_cases/get_activity_feed.rs +++ b/crates/application/src/use_cases/get_activity_feed.rs @@ -11,6 +11,13 @@ pub async fn execute( ctx: &AppContext, query: GetActivityFeedQuery, ) -> Result, DomainError> { - let page = PageParams::new(query.limit, query.offset)?; - ctx.diary_repository.query_activity_feed(&page).await + let page = PageParams::new(Some(query.limit), Some(query.offset))?; + ctx.diary_repository + .query_activity_feed_filtered( + &page, + &query.sort_by, + query.search.as_deref(), + query.following.as_ref(), + ) + .await } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 6bd82db..04e2040 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -15,6 +15,54 @@ use crate::{ }, }; +#[derive(Debug, Clone, Default, PartialEq)] +pub enum FeedSortBy { + #[default] + Date, + DateAsc, + Rating, + RatingAsc, +} + +impl FeedSortBy { + pub fn from_str(s: &str) -> Self { + match s { + "date_asc" => Self::DateAsc, + "rating" => Self::Rating, + "rating_asc" => Self::RatingAsc, + _ => Self::Date, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct FollowingFilter { + pub local_user_ids: Vec, + pub remote_actor_urls: Vec, +} + +#[derive(Debug, Clone)] +pub struct RemoteActorInfo { + pub url: String, + pub handle: String, + pub display_name: Option, +} + +/// New trait for social/federation read queries +#[async_trait] +pub trait SocialQueryPort: Send + Sync { + /// Returns all accepted remote_actor_urls followed by `user_id`. + async fn get_accepted_following_urls( + &self, + user_id: uuid::Uuid, + ) -> Result, DomainError>; + + /// Returns all distinct remote actors followed by any local user on this instance. + async fn list_all_followed_remote_actors( + &self, + ) -> Result, DomainError>; +} + #[async_trait] pub trait MovieRepository: Send + Sync { async fn get_movie_by_external_id( @@ -47,6 +95,13 @@ pub trait DiaryRepository: Send + Sync { &self, page: &PageParams, ) -> Result, DomainError>; + async fn query_activity_feed_filtered( + &self, + page: &PageParams, + sort_by: &FeedSortBy, + search: Option<&str>, + following: Option<&FollowingFilter>, + ) -> Result, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result; async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError>; } diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index acf2cf8..3417c2a 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -67,6 +67,18 @@ pub struct ErrorQuery { pub error: Option, } +#[derive(serde::Deserialize, Default)] +pub struct FeedQueryParams { + #[serde(default)] + pub filter: String, + #[serde(default)] + pub sort_by: String, + #[serde(default)] + pub search: String, + pub limit: Option, + pub offset: Option, +} + #[derive(Deserialize, Default)] pub struct DeleteRedirectForm { #[serde(default)] diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index d18faac..a996190 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -177,6 +177,15 @@ mod tests { ) -> Result, DomainError> { panic!() } + async fn query_activity_feed_filtered( + &self, + _: &PageParams, + _: &domain::ports::FeedSortBy, + _: Option<&str>, + _: Option<&domain::ports::FollowingFilter>, + ) -> Result, DomainError> { + panic!() + } async fn get_review_history(&self, _: &MovieId) -> Result { panic!() } @@ -185,6 +194,20 @@ mod tests { } } #[async_trait::async_trait] + impl domain::ports::SocialQueryPort for Panic { + async fn get_accepted_following_urls( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + panic!() + } + async fn list_all_followed_remote_actors( + &self, + ) -> Result, DomainError> { + panic!() + } + } + #[async_trait::async_trait] impl StatsRepository for Panic { async fn get_user_stats(&self, _: &UserId) -> Result { panic!() @@ -386,6 +409,7 @@ mod tests { html_renderer: Arc::new(Panic), rss_renderer: Arc::new(Panic), ap_service: Arc::new(activitypub::NoopActivityPubService), + social_query: Arc::new(Panic), } } diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index c9e61fe..e67db20 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -30,7 +30,7 @@ pub mod html { use crate::{ csrf::CsrfToken, dtos::{ - DiaryQueryParams, ErrorQuery, FollowForm, FollowerActionForm, LogReviewData, + ErrorQuery, FeedQueryParams, FollowForm, FollowerActionForm, LogReviewData, LogReviewForm, LoginForm, RegisterForm, UnfollowForm, }, extractors::{OptionalCookieUser, RequiredCookieUser}, @@ -338,29 +338,87 @@ pub mod html { pub async fn get_activity_feed( OptionalCookieUser(user_id): OptionalCookieUser, State(state): State, - Query(params): Query, + Query(params): Query, Extension(csrf): Extension, ) -> impl IntoResponse { - let ctx = build_page_context(&state, user_id, csrf.0).await; - let query = application::queries::GetActivityFeedQuery { - limit: params.limit, - offset: params.offset, + let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + + let filter_str = if params.filter == "following" && user_id.is_some() { + "following" + } else { + "all" }; + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + + let following = if filter_str == "following" { + if let Some(uid) = user_id { + let urls = state.social_query + .get_accepted_following_urls(uid.value()) + .await + .unwrap_or_default(); + let base_url = &state.app_ctx.config.base_url; + let mut local_ids = vec![uid.value()]; + let mut remote_urls = Vec::new(); + for url in urls { + if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) { + if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) { + local_ids.push(parsed_id); + continue; + } + } + remote_urls.push(url); + } + Some(domain::ports::FollowingFilter { + local_user_ids: local_ids, + remote_actor_urls: remote_urls, + }) + } else { + None + } + } else { + None + }; + + let search_opt = if params.search.is_empty() { + None + } else { + Some(params.search.clone()) + }; + + let query = application::queries::GetActivityFeedQuery { + limit, + offset, + sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), + search: search_opt, + following, + }; + match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await { Ok(entries) => { - let limit = entries.limit; - let offset = entries.offset; - let has_more = (offset as u64).saturating_add(limit as u64) < entries.total_count; + let entry_limit = entries.limit; + let entry_offset = entries.offset; + let has_more = (entry_offset as u64).saturating_add(entry_limit as u64) + < entries.total_count; let data = application::ports::ActivityFeedPageData { ctx, - current_offset: offset, + current_offset: entry_offset, has_more, - limit, + limit: entry_limit, entries, + filter: filter_str.to_string(), + sort_by: sort_by_str.to_string(), + search: params.search, }; match state.html_renderer.render_activity_feed_page(data) { Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), @@ -375,20 +433,37 @@ pub mod html { let mut ctx = build_page_context(&state, user_id, csrf.0).await; ctx.page_title = "Members — Movies Diary".to_string(); ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); - match application::use_cases::get_users::execute( - &state.app_ctx, - application::queries::GetUsersQuery, - ) - .await - { - Ok(users) => { - let data = application::ports::UsersPageData { ctx, users }; + + let (users_result, actors_result) = tokio::join!( + application::use_cases::get_users::execute( + &state.app_ctx, + application::queries::GetUsersQuery, + ), + state.social_query.list_all_followed_remote_actors() + ); + + match (users_result, actors_result) { + (Ok(users), Ok(remote_actors)) => { + let actor_views = remote_actors + .into_iter() + .map(|a| application::ports::RemoteActorView { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(); + let data = application::ports::UsersPageData { + ctx, + users, + remote_actors: actor_views, + }; match state.html_renderer.render_users_page(data) { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), } } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + (Err(e), _) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + (_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } @@ -1352,7 +1427,13 @@ pub mod api { ) -> Result, ApiError> { let page = get_feed_uc::execute( &state.app_ctx, - GetActivityFeedQuery { limit: params.limit, offset: params.offset }, + GetActivityFeedQuery { + limit: params.limit.unwrap_or(20), + offset: params.offset.unwrap_or(0), + sort_by: domain::ports::FeedSortBy::Date, + search: None, + following: None, + }, ) .await?; Ok(Json(ActivityFeedResponse { diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 9be2881..4692b3f 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -114,6 +114,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { // Federation let federation_repo = Arc::new(SqliteFederationRepository::new(pool)); + let social_query: Arc = Arc::clone(&federation_repo) as _; let user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository))); let review_handler = Arc::new(ReviewObjectHandler { movie_repository: Arc::clone(&movie_repository), @@ -170,6 +171,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), )), ap_service, + social_query, }; Ok((state, ap_router)) } diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index fc28a70..48b618d 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1,4 +1,3 @@ -use std::net::SocketAddr; use std::num::NonZeroU32; use axum::{Router, routing}; @@ -130,17 +129,38 @@ fn api_routes(rate_limit: u64) -> Router { .route("/auth/login", routing::post(handlers::api::login)) .route("/auth/register", routing::post(handlers::api::register)) .route("/diary/export", routing::get(handlers::api::export_diary)) - .route("/activity-feed", routing::get(handlers::api::get_activity_feed)) + .route( + "/activity-feed", + routing::get(handlers::api::get_activity_feed), + ) .route("/users", routing::get(handlers::api::list_users)) .route("/users/{id}", routing::get(handlers::api::get_user_profile)) - .route("/social/following", routing::get(handlers::api::get_following)) - .route("/social/followers", routing::get(handlers::api::get_followers)) - .route("/social/followers/pending", routing::get(handlers::api::get_pending_followers)) + .route( + "/social/following", + routing::get(handlers::api::get_following), + ) + .route( + "/social/followers", + routing::get(handlers::api::get_followers), + ) + .route( + "/social/followers/pending", + routing::get(handlers::api::get_pending_followers), + ) .route("/social/follow", routing::post(handlers::api::follow)) .route("/social/unfollow", routing::post(handlers::api::unfollow)) - .route("/social/followers/accept", routing::post(handlers::api::accept_follower)) - .route("/social/followers/reject", routing::post(handlers::api::reject_follower)) - .route("/social/followers/remove", routing::post(handlers::api::remove_follower)) + .route( + "/social/followers/accept", + routing::post(handlers::api::accept_follower), + ) + .route( + "/social/followers/reject", + routing::post(handlers::api::reject_follower), + ) + .route( + "/social/followers/remove", + routing::post(handlers::api::remove_follower), + ) .layer(GovernorLayer::new(cfg)), ) } diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 36aa138..8c1abcb 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -11,4 +11,5 @@ pub struct AppState { pub html_renderer: Arc, pub rss_renderer: Arc, pub ap_service: Arc, + pub social_query: Arc, } diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 5511845..c548060 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -125,6 +125,22 @@ impl domain::ports::DiaryExporter for PanicExporter { } } +struct PanicSocialQuery; +#[async_trait::async_trait] +impl domain::ports::SocialQueryPort for PanicSocialQuery { + async fn get_accepted_following_urls( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + panic!() + } + async fn list_all_followed_remote_actors( + &self, + ) -> Result, DomainError> { + panic!() + } +} + async fn test_app() -> Router { let pool = SqlitePool::connect("sqlite::memory:") .await @@ -156,6 +172,7 @@ async fn test_app() -> Router { html_renderer: Arc::new(AskamaHtmlRenderer::new()), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), ap_service: Arc::new(activitypub::NoopActivityPubService), + social_query: Arc::new(PanicSocialQuery), }; routes::build_router(state, axum::Router::new()) diff --git a/static/style.css b/static/style.css index d99dcc5..42e7679 100644 --- a/static/style.css +++ b/static/style.css @@ -29,7 +29,7 @@ body { font-family: "Nunito", sans-serif; max-width: 720px; margin: 0 auto; - padding: 20px; + padding: 20px 20px 56px; color: var(--text); background: url("/static/background.avif") center / cover no-repeat fixed; min-height: 100%; @@ -192,7 +192,9 @@ nav a:hover { .star.filled { color: var(--primary); - text-shadow: 0 0 8px var(--primary-glow), 0 0 2px var(--primary); + text-shadow: + 0 0 8px var(--primary-glow), + 0 0 2px var(--primary); } .star.empty { @@ -411,118 +413,272 @@ form button[type="submit"]:hover { text-decoration: none; font-weight: 600; } -.feed-user:hover { text-decoration: underline; } -.feed-time { opacity: 0.6; } +.feed-user:hover { + text-decoration: underline; +} +.feed-time { + opacity: 0.6; +} /* ---- Users list ---- */ -.users-list { display: flex; flex-direction: column; gap: 0.75rem; } -.page-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 1rem; opacity: 0.9; } +.users-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.page-title { + font-size: 1.2rem; + font-weight: 700; + margin-bottom: 1rem; + opacity: 0.9; +} .user-row { display: flex; align-items: center; gap: 1rem; - background: rgba(255,255,255,0.07); + background: rgba(255, 255, 255, 0.07); border-radius: 12px; padding: 0.75rem 1rem; } .user-avatar { - width: 40px; height: 40px; + width: 40px; + height: 40px; border-radius: 50%; - background: rgba(74,158,255,0.2); - display: flex; align-items: center; justify-content: center; - font-size: 1.1rem; font-weight: 700; + background: rgba(74, 158, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + font-weight: 700; flex-shrink: 0; } -.user-info { flex: 1; } -.user-name { font-weight: 600; font-size: 0.95rem; } -.user-meta { font-size: 0.8rem; opacity: 0.6; margin-top: 0.1rem; } +.user-info { + flex: 1; +} +.user-name { + font-weight: 600; + font-size: 0.95rem; +} +.user-meta { + font-size: 0.8rem; + opacity: 0.6; + margin-top: 0.1rem; +} .btn-secondary { color: var(--primary); font-size: 0.85rem; text-decoration: none; white-space: nowrap; } -.btn-secondary:hover { text-decoration: underline; } +.btn-secondary:hover { + text-decoration: underline; +} /* ---- Profile stats header ---- */ -.profile { display: flex; flex-direction: column; gap: 1rem; } +.profile { + display: flex; + flex-direction: column; + gap: 1rem; +} .stats-header { - background: rgba(255,255,255,0.06); + background: rgba(255, 255, 255, 0.06); border-radius: 14px; padding: 1rem 1.25rem; } -.profile-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.75rem; } -.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; } +.profile-name { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.75rem; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; +} .stat-tile { - background: rgba(255,255,255,0.06); + background: rgba(255, 255, 255, 0.06); border-radius: 10px; padding: 0.6rem 0.5rem; text-align: center; } -.stat-value { font-size: 1.1rem; font-weight: 700; color: var(--primary); } -.stat-label { font-size: 0.7rem; opacity: 0.5; margin-top: 0.1rem; } +.stat-value { + font-size: 1.1rem; + font-weight: 700; + color: var(--primary); +} +.stat-label { + font-size: 0.7rem; + opacity: 0.5; + margin-top: 0.1rem; +} /* ---- View tabs ---- */ -.view-tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; } +.view-tabs { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} .view-tab { padding: 0.3rem 0.75rem; border-radius: 8px; font-size: 0.85rem; text-decoration: none; - color: rgba(255,255,255,0.7); - background: rgba(255,255,255,0.08); + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.08); transition: background 0.15s; } -.view-tab:hover { background: rgba(255,255,255,0.14); } -.view-tab.active { background: var(--primary); color: #fff; font-weight: 600; } +.view-tab:hover { + background: rgba(255, 255, 255, 0.14); +} +.view-tab.active { + background: var(--primary); + color: #fff; + font-weight: 600; +} /* ---- History heatmap ---- */ -.heatmap-section { background: rgba(255,255,255,0.06); border-radius: 12px; padding: 1rem; } -.heatmap-label { font-size: 0.8rem; opacity: 0.5; margin-bottom: 0.6rem; } -.heatmap { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; } +.heatmap-section { + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 1rem; +} +.heatmap-label { + font-size: 0.8rem; + opacity: 0.5; + margin-bottom: 0.6rem; +} +.heatmap { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 4px; +} .heatmap-cell { border-radius: 6px; padding: 0.4rem 0.2rem; text-align: center; min-height: 48px; - display: flex; flex-direction: column; align-items: center; justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; background: oklch(85.2% 0.199 91.936 / var(--alpha, 0.05)); } -.heatmap-count { font-size: 0.85rem; font-weight: 700; } -.heatmap-month { font-size: 0.65rem; opacity: 0.6; margin-top: 2px; } +.heatmap-count { + font-size: 0.85rem; + font-weight: 700; +} +.heatmap-month { + font-size: 0.65rem; + opacity: 0.6; + margin-top: 2px; +} /* ---- History month sections ---- */ -.history-month { margin-top: 1rem; } -.month-heading { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.5rem; opacity: 0.8; } -.month-count { font-size: 0.8rem; opacity: 0.5; font-weight: 400; } +.history-month { + margin-top: 1rem; +} +.month-heading { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.5rem; + opacity: 0.8; +} +.month-count { + font-size: 0.8rem; + opacity: 0.5; + font-weight: 400; +} /* ---- Trends charts ---- */ -.trends-section { display: flex; flex-direction: column; gap: 1.25rem; } -.chart-block { background: rgba(255,255,255,0.06); border-radius: 12px; padding: 1rem; } -.chart-label { font-size: 0.8rem; opacity: 0.5; margin-bottom: 0.75rem; } +.trends-section { + display: flex; + flex-direction: column; + gap: 1.25rem; +} +.chart-block { + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 1rem; +} +.chart-label { + font-size: 0.8rem; + opacity: 0.5; + margin-bottom: 0.75rem; +} .bar-chart { display: flex; align-items: flex-end; gap: 4px; } -.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; } -.bar-value { font-size: 0.6rem; color: var(--primary); opacity: 0.9; line-height: 1; } -.bar-fill { width: 100%; background: var(--primary); border-radius: 3px 3px 0 0; min-height: 3px; opacity: 0.8; } -.bar-month { font-size: 0.65rem; opacity: 0.5; } +.bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.bar-value { + font-size: 0.6rem; + color: var(--primary); + opacity: 0.9; + line-height: 1; +} +.bar-fill { + width: 100%; + background: var(--primary); + border-radius: 3px 3px 0 0; + min-height: 3px; + opacity: 0.8; +} +.bar-month { + font-size: 0.65rem; + opacity: 0.5; +} -.director-chart { display: flex; flex-direction: column; gap: 6px; } -.director-row { display: flex; align-items: center; gap: 0.6rem; } -.director-name { font-size: 0.85rem; width: 140px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.director-bar { flex: 1; background: rgba(255,255,255,0.08); border-radius: 4px; height: 10px; overflow: hidden; } -.director-bar-fill { height: 100%; background: var(--primary); border-radius: 4px; opacity: 0.8; } -.director-count { font-size: 0.8rem; opacity: 0.5; width: 20px; text-align: right; } +.director-chart { + display: flex; + flex-direction: column; + gap: 6px; +} +.director-row { + display: flex; + align-items: center; + gap: 0.6rem; +} +.director-name { + font-size: 0.85rem; + width: 140px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.director-bar { + flex: 1; + background: rgba(255, 255, 255, 0.08); + border-radius: 4px; + height: 10px; + overflow: hidden; +} +.director-bar-fill { + height: 100%; + background: var(--primary); + border-radius: 4px; + opacity: 0.8; +} +.director-count { + font-size: 0.8rem; + opacity: 0.5; + width: 20px; + text-align: right; +} /* ---- ActivityPub federation ---- */ .remote-badge { font-size: 0.7rem; - color: rgba(255,255,255,0.5); - background: rgba(255,255,255,0.08); + color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.08); border-radius: 3px; padding: 1px 5px; margin-left: 4px; @@ -531,20 +687,23 @@ form button[type="submit"]:hover { .follow-section { margin-top: 1.5rem; padding-top: 1rem; - border-top: 1px solid rgba(255,255,255,0.1); + border-top: 1px solid rgba(255, 255, 255, 0.1); } .follow-section input[type="text"] { padding: 0.4rem; - border: 1px solid rgba(255,255,255,0.2); + border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 3px; min-width: 280px; - background: rgba(255,255,255,0.08); + background: rgba(255, 255, 255, 0.08); color: inherit; } -.following-list { list-style: none; padding: 0; } +.following-list { + list-style: none; + padding: 0; +} .following-item { padding: 0.5rem 0; - border-bottom: 1px solid rgba(255,255,255,0.07); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); display: flex; align-items: center; gap: 0.5rem; @@ -554,7 +713,7 @@ form button[type="submit"]:hover { .pending-followers { margin-top: 1.5rem; padding-top: 1rem; - border-top: 1px solid rgba(255,255,255,0.1); + border-top: 1px solid rgba(255, 255, 255, 0.1); } .pending-list { @@ -567,7 +726,7 @@ form button[type="submit"]:hover { align-items: center; gap: 0.5rem; padding: 0.4rem 0; - border-bottom: 1px solid rgba(255,255,255,0.07); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); flex-wrap: wrap; } @@ -618,3 +777,210 @@ form button[type="submit"]:hover { .btn-reject:hover { background: rgba(220, 60, 60, 0.85); } + +/* ── Feed filter bar ─────────────────────────────── */ +.feed-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + max-width: none; /* override global form { max-width: 400px } */ + background: var(--glass-bg, rgba(255, 255, 255, 0.06)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.12)); + border-radius: 12px; + backdrop-filter: blur(8px); +} + +.pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.85rem; + border-radius: 999px; + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.18)); + background: transparent; + color: var(--text-muted, rgba(255, 255, 255, 0.55)); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; + user-select: none; +} + +.pill input[type="radio"] { + display: none; +} + +.pill:hover { + background: var(--glass-bg, rgba(255, 255, 255, 0.08)); + color: var(--text-primary, #fff); +} + +.pill.active { + background: var(--primary); + border-color: transparent; + color: #fff; +} + +.feed-controls { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.feed-controls select { + background: var(--glass-bg, rgba(255, 255, 255, 0.06)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.18)); + border-radius: 8px; + color: inherit; + font-size: 0.875rem; + padding: 0.3rem 0.6rem; + cursor: pointer; + color-scheme: dark; +} + +.feed-controls select option { + background: #1e1a2e; + color: #fff; +} + +.feed-controls input[type="text"] { + background: var(--glass-bg, rgba(255, 255, 255, 0.06)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.18)); + border-radius: 8px; + color: inherit; + font-size: 0.875rem; + padding: 0.3rem 0.7rem; + width: 180px; +} + +.feed-controls input[type="text"]::placeholder { + color: var(--text-muted, rgba(255, 255, 255, 0.4)); +} + +.btn-search { + background: var(--glass-bg, rgba(255, 255, 255, 0.08)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.18)); + border-radius: 8px; + color: inherit; + font-size: 0.875rem; + padding: 0.3rem 0.8rem; + cursor: pointer; + transition: background 0.15s; +} + +.btn-search:hover { + background: var(--glass-bg-hover, rgba(255, 255, 255, 0.14)); +} + +.clear-filters { + font-size: 0.8rem; + color: var(--text-muted, rgba(255, 255, 255, 0.45)); + text-decoration: none; + padding: 0.3rem 0.5rem; + border-radius: 6px; + transition: color 0.15s; +} + +.clear-filters:hover { + color: var(--text-primary, #fff); +} + +/* ── Users — federated section ───────────────────── */ +.federated-title { + margin-top: 2rem; +} + +.federated-avatar { + opacity: 0.75; +} + +.user-meta.muted, +.muted { + color: var(--text-muted, rgba(255, 255, 255, 0.45)); + font-size: 0.8rem; +} + +/* ── Site footer ─────────────────────────────────── */ +.site-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem 0.75rem; + padding: 0.6rem 1rem; + border-top: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1)); + font-size: 0.8rem; + color: var(--text-muted, rgba(255, 255, 255, 0.4)); + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + z-index: 100; +} + +.footer-link { + color: var(--text-muted, rgba(255, 255, 255, 0.45)); + text-decoration: none; + transition: color 0.15s; +} + +.footer-link:hover { + color: var(--text-primary, #fff); +} + +.footer-sep { + color: var(--text-muted, rgba(255, 255, 255, 0.25)); +} + +/* ── Responsive: mobile ──────────────────────────── */ +@media (max-width: 560px) { + header { + flex-wrap: wrap; + gap: 10px; + } + + nav { + flex-wrap: wrap; + width: 100%; + gap: 6px; + } + + nav a { + font-size: 0.8em; + padding: 3px 10px; + } +} + +@media (max-width: 480px) { + .feed-filters { + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + } + + .feed-controls { + width: 100%; + } + + .feed-controls select, + .feed-controls input[type="text"] { + flex: 1; + width: auto; + } + + .pill { + flex: 1; + justify-content: center; + } +}