diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 7e8a362..e1fc6bd 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -85,8 +85,8 @@ impl SqliteMovieRepository { offset: i64, ) -> Result, DomainError> { match sort { - // ByRatingDesc only applies to user-scoped queries; falls back to date sort here - SortDirection::Descending | SortDirection::ByRatingDesc => sqlx::query_as!( + // ByRatingDesc/ByRatingAsc only apply to user-scoped queries; fall back to date sort here + SortDirection::Descending | SortDirection::ByRatingDesc | SortDirection::ByRatingAsc => sqlx::query_as!( DiaryRow, "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 @@ -126,8 +126,8 @@ impl SqliteMovieRepository { offset: i64, ) -> Result, DomainError> { match sort { - // ByRatingDesc only applies to user-scoped queries; falls back to date sort here - SortDirection::Descending | SortDirection::ByRatingDesc => sqlx::query_as!( + // ByRatingDesc/ByRatingAsc only apply to user-scoped queries; fall back to date sort here + SortDirection::Descending | SortDirection::ByRatingDesc | SortDirection::ByRatingAsc => sqlx::query_as!( DiaryRow, "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 @@ -163,57 +163,71 @@ impl SqliteMovieRepository { } } - async fn count_user_diary_entries(&self, user_id: &str) -> Result { - sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE user_id = ?", user_id) - .fetch_one(&self.pool) + async fn count_user_diary_entries( + &self, + user_id: &str, + search: Option<&str>, + ) -> Result { + let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); + let sql = if has_search { + "SELECT COUNT(*) FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND m.title LIKE '%' || ? || '%'" + .to_string() + } else { + "SELECT COUNT(*) FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ?" + .to_string() + }; + let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); + if has_search { + q = q.bind(search.unwrap()); + } + q.fetch_one(&self.pool).await.map_err(Self::map_err) + } + + async fn fetch_user_diary_rows( + &self, + user_id: &str, + sort: &SortDirection, + search: Option<&str>, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); + let search_clause = if has_search { + " AND m.title LIKE '%' || ? || '%'" + } else { + "" + }; + let order_clause = match sort { + SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC", + SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC", + SortDirection::Ascending => "r.watched_at ASC", + SortDirection::Descending => "r.watched_at DESC", + }; + let 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 + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ?{} + ORDER BY {} + LIMIT ? OFFSET ?", + search_clause, order_clause + ); + let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); + if has_search { + q = q.bind(search.unwrap()); + } + q.bind(limit) + .bind(offset) + .fetch_all(&self.pool) .await .map_err(Self::map_err) } - async fn fetch_user_diary_rows_by_watched( - &self, - user_id: &str, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - sqlx::query_as!( - DiaryRow, - "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 - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? - ORDER BY r.watched_at DESC - LIMIT ? OFFSET ?", - user_id, limit, offset - ) - .fetch_all(&self.pool) - .await - .map_err(Self::map_err) - } - - async fn fetch_user_diary_rows_by_rating( - &self, - user_id: &str, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - sqlx::query_as!( - DiaryRow, - "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 - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? - ORDER BY r.rating DESC, r.watched_at DESC - LIMIT ? OFFSET ?", - user_id, 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, @@ -460,16 +474,11 @@ impl DiaryRepository for SqliteMovieRepository { } (None, Some(uid)) => { let uid_str = uid.value().to_string(); - match &filter.sort_by { - SortDirection::ByRatingDesc => tokio::try_join!( - self.count_user_diary_entries(&uid_str), - self.fetch_user_diary_rows_by_rating(&uid_str, limit, offset) - )?, - _ => tokio::try_join!( - self.count_user_diary_entries(&uid_str), - self.fetch_user_diary_rows_by_watched(&uid_str, limit, offset) - )?, - } + let search = filter.search.as_deref(); + tokio::try_join!( + self.count_user_diary_entries(&uid_str, search), + self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset) + )? } (Some(_), Some(_)) => { return Err(DomainError::ValidationError( diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 6e6713e..ca69321 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -163,6 +163,26 @@ struct ProfileTemplate<'a> { following_count: usize, followers_count: usize, pending_followers: Vec, + pub sort_by: String, + pub search: String, +} + +impl<'a> ProfileTemplate<'a> { + pub fn filter_qs(&self) -> String { + let mut parts = vec![ + format!("view={}", self.view), + 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("&")) + } } struct RemoteActorData { @@ -493,6 +513,8 @@ impl HtmlRenderer for AskamaHtmlRenderer { display_name: a.display_name, }) .collect(), + sort_by: data.sort_by.clone(), + search: data.search.clone(), } .render() .map_err(|e| e.to_string()) diff --git a/crates/adapters/template-askama/templates/_filter_controls.html b/crates/adapters/template-askama/templates/_filter_controls.html new file mode 100644 index 0000000..8e4dcff --- /dev/null +++ b/crates/adapters/template-askama/templates/_filter_controls.html @@ -0,0 +1,10 @@ +
+ + + +
diff --git a/crates/adapters/template-askama/templates/activity_feed.html b/crates/adapters/template-askama/templates/activity_feed.html index f33a3ba..44b7247 100644 --- a/crates/adapters/template-askama/templates/activity_feed.html +++ b/crates/adapters/template-askama/templates/activity_feed.html @@ -15,19 +15,10 @@ Following {% endif %} -
- - - - {% if filter != "all" || sort_by != "date" || !search.is_empty() %} - Clear - {% endif %} -
+ {% include "_filter_controls.html" %} + {% if filter != "all" || sort_by != "date" || !search.is_empty() %} + Clear + {% endif %}
diff --git a/crates/adapters/template-askama/templates/followers.html b/crates/adapters/template-askama/templates/followers.html index e98d6e6..cf104a3 100644 --- a/crates/adapters/template-askama/templates/followers.html +++ b/crates/adapters/template-askama/templates/followers.html @@ -14,7 +14,7 @@ {% if let Some(name) = actor.display_name %} ({{ name }}) {% endif %} - {{ actor.url }} + View profile ↗
diff --git a/crates/adapters/template-askama/templates/following.html b/crates/adapters/template-askama/templates/following.html index 4070a18..96074ac 100644 --- a/crates/adapters/template-askama/templates/following.html +++ b/crates/adapters/template-askama/templates/following.html @@ -14,7 +14,7 @@ {% if let Some(name) = actor.display_name %} ({{ name }}) {% endif %} - {{ actor.url }} + View profile ↗ diff --git a/crates/adapters/template-askama/templates/profile.html b/crates/adapters/template-askama/templates/profile.html index 0da26ae..ed40446 100644 --- a/crates/adapters/template-askama/templates/profile.html +++ b/crates/adapters/template-askama/templates/profile.html @@ -75,6 +75,17 @@ Trends
+ {% if view == "recent" || view == "ratings" %} + + + + {% include "_filter_controls.html" %} + {% if sort_by != "date" || !search.is_empty() %} + Clear + {% endif %} + + {% endif %} + {% if view == "history" %} {% if let Some(hist) = history %}
@@ -185,7 +196,7 @@
{{ entry.review().watched_at().format("%Y-%m-%d") }}
{% if ctx.is_current_user(entry.review().user_id().value()) %}
- +
@@ -198,7 +209,7 @@
{% endif %} diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 0441271..c61341f 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -76,6 +76,8 @@ pub struct ProfilePageData { pub following_count: usize, pub followers_count: usize, pub pending_followers: Vec, + pub sort_by: String, + pub search: String, } pub struct FollowingPageData { diff --git a/crates/application/src/queries.rs b/crates/application/src/queries.rs index 3bc22e3..d958161 100644 --- a/crates/application/src/queries.rs +++ b/crates/application/src/queries.rs @@ -61,4 +61,6 @@ pub struct GetUserProfileQuery { pub view: ProfileView, pub limit: Option, pub offset: Option, + pub sort_by: domain::ports::FeedSortBy, + pub search: Option, } diff --git a/crates/application/src/use_cases/get_diary.rs b/crates/application/src/use_cases/get_diary.rs index 4859588..1119d20 100644 --- a/crates/application/src/use_cases/get_diary.rs +++ b/crates/application/src/use_cases/get_diary.rs @@ -22,6 +22,7 @@ pub async fn execute( page, movie_id, user_id, + search: None, }; ctx.diary_repository.query_diary(&filter).await diff --git a/crates/application/src/use_cases/get_user_profile.rs b/crates/application/src/use_cases/get_user_profile.rs index 82ec005..b2c2454 100644 --- a/crates/application/src/use_cases/get_user_profile.rs +++ b/crates/application/src/use_cases/get_user_profile.rs @@ -9,6 +9,7 @@ use domain::{ DiaryEntry, DiaryFilter, MonthActivity, SortDirection, UserStats, UserTrends, collections::{PageParams, Paginated}, }, + ports::FeedSortBy, value_objects::UserId, }; @@ -47,11 +48,13 @@ pub async fn execute( }) } ProfileView::Ratings => { + let sort_direction = feed_sort_to_direction(query.sort_by); let filter = paged_user_filter( user_id, - SortDirection::ByRatingDesc, + sort_direction, query.limit, query.offset, + query.search.clone(), )?; let entries = ctx.diary_repository.query_diary(&filter).await?; Ok(UserProfileData { @@ -62,11 +65,13 @@ pub async fn execute( }) } ProfileView::Recent => { + let sort_direction = feed_sort_to_direction(query.sort_by); let filter = paged_user_filter( user_id, - SortDirection::Descending, + sort_direction, query.limit, query.offset, + query.search.clone(), )?; let entries = ctx.diary_repository.query_diary(&filter).await?; Ok(UserProfileData { @@ -79,11 +84,21 @@ pub async fn execute( } } +fn feed_sort_to_direction(sort_by: FeedSortBy) -> SortDirection { + match sort_by { + FeedSortBy::Date => SortDirection::Descending, + FeedSortBy::DateAsc => SortDirection::Ascending, + FeedSortBy::Rating => SortDirection::ByRatingDesc, + FeedSortBy::RatingAsc => SortDirection::ByRatingAsc, + } +} + fn paged_user_filter( user_id: UserId, sort_by: SortDirection, limit: Option, offset: Option, + search: Option, ) -> Result { let page = PageParams::new(limit, offset)?; Ok(DiaryFilter { @@ -91,6 +106,7 @@ fn paged_user_filter( page, movie_id: None, user_id: Some(user_id), + search, }) } diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 378cddf..7b68ccd 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -16,6 +16,7 @@ pub enum SortDirection { Descending, Ascending, ByRatingDesc, + ByRatingAsc, } #[derive(Clone, Debug, Default)] @@ -24,6 +25,7 @@ pub struct DiaryFilter { pub page: PageParams, pub movie_id: Option, pub user_id: Option, + pub search: Option, } #[derive(Clone, Debug)] diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index 3417c2a..f4a3f88 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -284,6 +284,10 @@ pub struct ProfileQueryParams { pub limit: Option, pub offset: Option, pub error: Option, + #[serde(default)] + pub sort_by: String, + #[serde(default)] + pub search: String, } // ── Activity feed ───────────────────────────────────────────────────────────── diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index e67db20..b6dfac0 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -529,6 +529,13 @@ pub mod html { state.app_ctx.config.base_url, profile_user_uuid ); + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + let is_own_profile = user_id .as_ref() .map(|u| u.value() == profile_user_uuid) @@ -580,6 +587,8 @@ pub mod html { view: profile_view, limit: params.limit, offset: params.offset, + sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), + search: if params.search.is_empty() { None } else { Some(params.search.clone()) }, }; match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await { @@ -611,6 +620,8 @@ pub mod html { following_count, followers_count, pending_followers, + sort_by: sort_by_str.to_string(), + search: params.search.clone(), }; match state.html_renderer.render_profile_page(data) { Ok(html) => Html(html).into_response(), @@ -1517,6 +1528,8 @@ pub mod api { view: profile_view, limit: params.limit, offset: params.offset, + sort_by: domain::ports::FeedSortBy::Date, + search: None, }, ) .await diff --git a/static/style.css b/static/style.css index 42e7679..c3673b6 100644 --- a/static/style.css +++ b/static/style.css @@ -706,6 +706,7 @@ form button[type="submit"]:hover { border-bottom: 1px solid rgba(255, 255, 255, 0.07); display: flex; align-items: center; + flex-wrap: wrap; gap: 0.5rem; }