use application::ports::{ ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData, ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, WatchlistPageData, }; use askama::Template; use chrono::Datelike; use domain::models::{ DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, collections::Paginated, }; mod filters { #[askama::filter_fn] pub fn poster_src( path: T, _env: &dyn askama::Values, ) -> askama::Result { let p = path.to_string(); if p.starts_with("http://") || p.starts_with("https://") { Ok(p) } else { Ok(format!("/images/{}", p)) } } } struct PageItem { number: u32, is_current: bool, is_ellipsis: bool, } fn build_page_items(total_pages: u32, current_page: u32) -> Vec { if total_pages <= 1 { return vec![]; } let mut set = std::collections::BTreeSet::new(); set.insert(0u32); set.insert(total_pages - 1); let start = current_page.saturating_sub(2); let end = (current_page + 2).min(total_pages - 1); for p in start..=end { set.insert(p); } let pages: Vec = set.into_iter().collect(); let mut items = Vec::new(); for (i, &p) in pages.iter().enumerate() { if i > 0 && p > pages[i - 1] + 1 { items.push(PageItem { number: 0, is_current: false, is_ellipsis: true, }); } items.push(PageItem { number: p, is_current: p == current_page, is_ellipsis: false, }); } items } #[derive(Template)] #[template(path = "diary.html")] struct DiaryTemplate<'a> { entries: &'a [DiaryEntry], current_offset: u32, limit: u32, has_more: bool, ctx: &'a HtmlPageContext, page_items: Vec, } #[derive(Template)] #[template(path = "login.html")] struct LoginTemplate<'a> { error: Option<&'a str>, ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "register.html")] struct RegisterTemplate<'a> { error: Option<&'a str>, ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "new_review.html")] struct NewReviewTemplate<'a> { error: Option<&'a str>, ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "activity_feed.html")] struct ActivityFeedTemplate<'a> { entries: &'a [FeedEntry], current_offset: u32, limit: u32, has_more: bool, ctx: &'a HtmlPageContext, page_items: Vec, pub filter: String, pub sort_by: String, pub search: String, } #[derive(Template)] #[template(path = "movie_detail.html")] struct MovieDetailTemplate<'a> { ctx: &'a HtmlPageContext, movie: &'a domain::models::Movie, stats: &'a domain::models::MovieStats, profile: Option<&'a domain::models::MovieProfile>, reviews: &'a [domain::models::FeedEntry], on_watchlist: bool, current_offset: u32, has_more: bool, limit: u32, histogram_max: u64, } #[derive(Template)] #[template(path = "watchlist.html")] struct WatchlistTemplate<'a> { ctx: &'a HtmlPageContext, owner_id: uuid::Uuid, display_entries: &'a [application::ports::WatchlistDisplayEntry], current_offset: u32, has_more: bool, limit: u32, is_owner: bool, error: Option, } 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 { user_id: uuid::Uuid, display_name: String, initial: char, avg_rating_display: String, total_movies: i64, avatar_url: Option, } #[derive(Template)] #[template(path = "users.html")] struct UsersTemplate<'a> { users: Vec, ctx: &'a HtmlPageContext, remote_actors: Vec, } struct MonthlyRatingRow<'a> { rating: &'a MonthlyRating, bar_height_px: i64, } #[derive(Template)] #[template(path = "profile.html")] struct ProfileTemplate<'a> { ctx: &'a HtmlPageContext, 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>, current_offset: u32, has_more: bool, limit: u32, history: Option<&'a Vec>, trends: Option<&'a UserTrends>, monthly_rating_rows: Vec>, heatmap: Vec, page_items: Vec, is_own_profile: bool, error: Option, 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 { handle: String, display_name: Option, url: String, avatar_url: Option, } #[derive(Template)] #[template(path = "following.html")] struct FollowingTemplate { ctx: HtmlPageContext, user_id: uuid::Uuid, actors: Vec, error: Option, } #[derive(Template)] #[template(path = "followers.html")] struct FollowersTemplate { ctx: HtmlPageContext, user_id: uuid::Uuid, actors: Vec, error: Option, } #[derive(Template)] #[template(path = "blocked_domains.html")] struct BlockedDomainsTemplate<'a> { ctx: &'a HtmlPageContext, domains: &'a [BlockedDomainEntry], } #[derive(Template)] #[template(path = "blocked_actors.html")] struct BlockedActorsTemplate<'a> { ctx: &'a HtmlPageContext, actors: &'a [BlockedActorEntry], } struct HeatmapCell { month_label: String, count: i64, alpha: f64, } #[allow(dead_code)] fn relative_time(dt: chrono::NaiveDateTime) -> String { let now = chrono::Utc::now().naive_utc(); let diff = now.signed_duration_since(dt); if diff.num_seconds() <= 0 { return "just now".to_string(); } let minutes = diff.num_minutes(); let hours = diff.num_hours(); let days = diff.num_days(); if minutes < 1 { return "just now".to_string(); } if minutes < 60 { return format!("{} min ago", minutes); } if hours < 24 { return format!("{} h ago", hours); } if days == 1 { return "yesterday".to_string(); } if days < 30 { return format!("{} days ago", days); } dt.format("%b %-d, %Y").to_string() } fn build_heatmap(history: &[MonthActivity]) -> Vec { let current_year = chrono::Utc::now().year(); let count_for = |m: &str| -> i64 { history .iter() .find(|a| a.year_month == format!("{}-{}", current_year, m)) .map(|a| a.count) .unwrap_or(0) }; let months = [ ("01", "Jan"), ("02", "Feb"), ("03", "Mar"), ("04", "Apr"), ("05", "May"), ("06", "Jun"), ("07", "Jul"), ("08", "Aug"), ("09", "Sep"), ("10", "Oct"), ("11", "Nov"), ("12", "Dec"), ]; let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); let max = counts.iter().copied().max().unwrap_or(0).max(1); months .iter() .zip(counts.iter()) .map(|((_, label), &count)| { let alpha = if count == 0 { 0.05 } else { 0.15 + 0.75 * (count as f64 / max as f64) }; HeatmapCell { month_label: label.to_string(), count, alpha, } }) .collect() } fn bar_height_px(avg_rating: f64) -> i64 { (avg_rating / 5.0 * 60.0) as i64 } #[derive(Template)] #[template(path = "profile_settings.html")] struct ProfileSettingsTemplate<'a> { ctx: &'a HtmlPageContext, bio: Option<&'a str>, avatar_url: Option<&'a str>, banner_url: Option<&'a str>, also_known_as: Option<&'a str>, profile_fields: &'a [(String, String)], saved: bool, } #[derive(Template)] #[template(path = "import_upload.html")] struct ImportUploadTemplate<'a> { ctx: &'a HtmlPageContext, profiles: &'a [ImportProfileView], error: Option<&'a str>, } #[derive(Template)] #[template(path = "import_mapping.html")] struct ImportMappingTemplate<'a> { ctx: &'a HtmlPageContext, session_id: &'a str, columns: &'a [String], sample_rows: &'a [Vec], domain_fields: &'a [(&'static str, &'static str)], error: Option<&'a str>, } #[derive(Template)] #[template(path = "import_preview.html")] struct ImportPreviewTemplate<'a> { ctx: &'a HtmlPageContext, session_id: &'a str, columns: &'a [String], rows: &'a [ImportPreviewRow], } #[derive(Default)] pub struct AskamaHtmlRenderer; impl AskamaHtmlRenderer { pub fn new() -> Self { Self {} } } impl HtmlRenderer for AskamaHtmlRenderer { fn render_diary_page( &self, data: &Paginated, ctx: HtmlPageContext, ) -> Result { let has_more = (data.offset + data.limit) < data.total_count as u32; let (total_pages, current_page) = if data.limit > 0 { let tp = data.total_count.div_ceil(data.limit as u64) as u32; (tp, data.offset / data.limit) } else { (0, 0) }; DiaryTemplate { entries: &data.items, current_offset: data.offset, limit: data.limit, has_more, ctx: &ctx, page_items: build_page_items(total_pages, current_page), } .render() .map_err(|e| e.to_string()) } fn render_login_page(&self, data: LoginPageData<'_>) -> Result { LoginTemplate { error: data.error, ctx: &data.ctx, } .render() .map_err(|e| e.to_string()) } fn render_register_page(&self, data: RegisterPageData<'_>) -> Result { RegisterTemplate { error: data.error, ctx: &data.ctx, } .render() .map_err(|e| e.to_string()) } fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result { NewReviewTemplate { error: data.error, ctx: &data.ctx, } .render() .map_err(|e| e.to_string()) } fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result { let limit = data.limit; let total_pages = data.entries.total_count.div_ceil(limit.max(1) as u64) as u32; let current_page = data.current_offset.checked_div(limit).unwrap_or(0); ActivityFeedTemplate { entries: &data.entries.items, current_offset: data.current_offset, limit, 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()) } fn render_users_page(&self, data: UsersPageData) -> Result { let users: Vec = 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, avatar_url: u.avatar_path.as_ref().map(|p| format!("/images/{}", p)), } }) .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()) } fn render_profile_page(&self, data: ProfilePageData) -> Result { let heatmap = data .history .as_deref() .map(build_heatmap) .unwrap_or_default(); let profile_display_name = data .profile_user_email .split('@') .next() .unwrap_or(&data.profile_user_email) .to_string(); let monthly_rating_rows: Vec> = data .trends .as_ref() .map(|t| { t.monthly_ratings .iter() .map(|r| MonthlyRatingRow { bar_height_px: bar_height_px(r.avg_rating), rating: r, }) .collect() }) .unwrap_or_default(); let total_pages = data .entries .as_ref() .map(|e| e.total_count.div_ceil(e.limit.max(1) as u64) as u32) .unwrap_or(0); let current_page = data.current_offset.checked_div(data.limit).unwrap_or(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, has_more: data.has_more, limit: data.limit, history: data.history.as_ref(), trends: data.trends.as_ref(), monthly_rating_rows, heatmap, page_items: build_page_items(total_pages, current_page), is_own_profile: data.is_own_profile, error: data.error, following_count: data.following_count, followers_count: data.followers_count, pending_followers: data .pending_followers .into_iter() .map(|a| RemoteActorData { handle: a.handle, url: a.url, display_name: a.display_name, avatar_url: a.avatar_url, }) .collect(), sort_by: data.sort_by.clone(), search: data.search.clone(), } .render() .map_err(|e| e.to_string()) } fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result { MovieDetailTemplate { ctx: &data.ctx, movie: &data.movie, stats: &data.stats, profile: data.profile.as_ref(), reviews: &data.reviews.items, on_watchlist: data.on_watchlist, current_offset: data.current_offset, has_more: data.has_more, limit: data.limit, histogram_max: data.histogram_max, } .render() .map_err(|e| e.to_string()) } fn render_watchlist_page(&self, data: WatchlistPageData) -> Result { WatchlistTemplate { ctx: &data.ctx, owner_id: data.owner_id, display_entries: &data.display_entries, current_offset: data.current_offset, has_more: data.has_more, limit: data.limit, is_owner: data.is_owner, error: data.error, } .render() .map_err(|e| e.to_string()) } fn render_following_page(&self, data: FollowingPageData) -> Result { FollowingTemplate { ctx: data.ctx, user_id: data.user_id, actors: data .actors .into_iter() .map(|a| RemoteActorData { handle: a.handle, display_name: a.display_name, url: a.url, avatar_url: a.avatar_url, }) .collect(), error: data.error, } .render() .map_err(|e| e.to_string()) } fn render_followers_page(&self, data: FollowersPageData) -> Result { FollowersTemplate { ctx: data.ctx, user_id: data.user_id, actors: data .actors .into_iter() .map(|a| RemoteActorData { handle: a.handle, display_name: a.display_name, url: a.url, avatar_url: a.avatar_url, }) .collect(), error: data.error, } .render() .map_err(|e| e.to_string()) } fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result { ImportUploadTemplate { ctx: &data.ctx, profiles: &data.profiles, error: data.error.as_deref(), } .render() .map_err(|e| e.to_string()) } fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result { ImportMappingTemplate { ctx: &data.ctx, session_id: &data.session_id, columns: &data.columns, sample_rows: &data.sample_rows, domain_fields: &data.domain_fields, error: data.error.as_deref(), } .render() .map_err(|e| e.to_string()) } fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result { ImportPreviewTemplate { ctx: &data.ctx, session_id: &data.session_id, columns: &data.columns, rows: &data.rows, } .render() .map_err(|e| e.to_string()) } fn render_profile_settings_page( &self, data: ProfileSettingsPageData, ) -> Result { ProfileSettingsTemplate { ctx: &data.ctx, bio: data.bio.as_deref(), avatar_url: data.avatar_url.as_deref(), banner_url: data.banner_url.as_deref(), also_known_as: data.also_known_as.as_deref(), profile_fields: &data.profile_fields, saved: data.saved, } .render() .map_err(|e| e.to_string()) } fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result { BlockedDomainsTemplate { ctx: &data.ctx, domains: &data.domains, } .render() .map_err(|e| e.to_string()) } fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result { BlockedActorsTemplate { ctx: &data.ctx, actors: &data.actors, } .render() .map_err(|e| e.to_string()) } }